import pandas as pd
import numpy as np
from datetime import datetime
from typing import Dict, Any, List, Optional
import ast
from numba import njit

@njit
def _jit_risk_engine(
    loan_principal, 
    irr_rate, 
    total_amount_paid, 
    emi_amounts, 
    pre_part_payment_amount, 
    adjustment_amount=0.0,
    use_principal_waiver=False
):
    """
    High-performance compiled math loop for POS/DPD calculation.
    """
    beginning_balance = loan_principal
    total_interest_paid = 0.0
    total_principal_paid = 0.0
    count_of_emi_paid = 0
    
    # Iterate through EMI schedule
    for emi_amount in emi_amounts:
        if emi_amount <= 0: continue
            
        # 1. Interest Calculation
        interest_component = min(beginning_balance * irr_rate, emi_amount)
        interest_paid = round(min(total_amount_paid, interest_component), 0)
        total_amount_paid -= interest_paid
        
        # 2. Principal Calculation
        principal_component = max(emi_amount - interest_component, 0.0)
        principal_available = total_amount_paid + adjustment_amount
        principal_paid = round(min(principal_available, principal_component), 0)
        
        # Deduct drawn principal
        if principal_paid <= total_amount_paid:
            total_amount_paid -= principal_paid
        else:
            adjusted_draw = principal_paid - total_amount_paid
            total_amount_paid = 0.0
            adjustment_amount -= adjusted_draw
        
        total_interest_paid += interest_paid
        total_principal_paid += principal_paid
        beginning_balance -= principal_paid

        # 3. Paydown check (500 waiver logic + Principal payoff check for After-Adj)
        is_paid_val = round(interest_paid + principal_paid, 0)
        
        # Before adjustment strictly uses (int + prin) >= emi - 500
        # After adjustment (use_principal_waiver=True) also allows count++ if principal is fully paid
        if emi_amount > 0:
            if is_paid_val >= (emi_amount - 500):
                count_of_emi_paid += 1
            elif use_principal_waiver and round(principal_paid, 0) >= round(principal_component, 0):
                count_of_emi_paid += 1
        
        if beginning_balance <= 0.001:
            break
            
    pos = max(0.0, loan_principal - total_principal_paid - pre_part_payment_amount)
    return total_interest_paid, total_principal_paid + pre_part_payment_amount, pos, float(count_of_emi_paid)

class RiskEngine:
    @staticmethod
    def pos_calculation(row: pd.Series) -> pd.Series:
        """Optimized POS calculation using Numba JIT."""
        emi_dict = row.get('emi_date_wise_amount_list', {})
        if not isinstance(emi_dict, dict) or not emi_dict:
            return pd.Series([0.0, 0.0, 0.0, 0.0])
        
        if row['application_status'] in ['closed', 'cancelled']:
            return pd.Series([0.0, float(row.get('pre_part_payment_amount', 0)), 0.0, 0.0])

        # Pre-process EMI list (Sorting is the slow part in row-apply, we minimize it)
        try:
            sorted_amounts = np.array([float(v) for _, v in sorted(emi_dict.items(), key=lambda x: x[0][-4:] + x[0][3:5] + x[0][0:2])], dtype=np.float64)
        except:
            return pd.Series([0.0, 0.0, 0.0, 0.0])
            
        res = _jit_risk_engine(
            float(row['loan_principal']),
            float(row['irr_rate'] / 100),
            float(row['amount_paid_for_emi']),
            sorted_amounts,
            float(row['pre_part_payment_amount']),
            0.0,
            False # Before Adjustment logic
        )
        return pd.Series(res)

    @staticmethod
    def calculate_first_unpaid_date(row: pd.Series) -> Optional[datetime]:
        """Extracts the (count_of_emi_paid + 1)th date as first_unpaid_emi_date."""
        emi_dates_list = row['emi_date_wise_amount_list']
        if not emi_dates_list or not isinstance(emi_dates_list, dict):
            return None
            
        count_of_emi_paid = int(row['count_of_emi_paid'])
        
        # Get only dates where emi amount > 0 (Using string sort for speed: DD-MM-YYYY -> YYYYMMDD)
        emi_dates_keys = [k for k, v in sorted(emi_dates_list.items(), key=lambda x: x[0][-4:] + x[0][3:5] + x[0][0:2]) if v > 0]
        
        if count_of_emi_paid < len(emi_dates_keys):
            res_date = emi_dates_keys[count_of_emi_paid]
            return datetime.strptime(res_date, '%d-%m-%Y')
        return None

    @staticmethod
    def apply_lender_overrides(df: pd.DataFrame, lender_df: Optional[pd.DataFrame] = None) -> pd.DataFrame:
        """Applies Lender CSV overrides (POS & DPD) and recalculates auxiliary fields."""
        # Use specific name if present in df (for Phase 3)
        dpd_col = 'current_month_revised_dpd' if 'current_month_revised_dpd' in df.columns else 'current_dpd'
        
        if lender_df is not None:
            # Force parity
            # Force parity using safe parsing (handles commas, floats, strings, nans)
            df['application_id'] = pd.to_numeric(df['application_id'].astype(str).str.replace(',', ''), errors='coerce').fillna(0).astype('int64')
            lender_df['app_id'] = pd.to_numeric(lender_df['app_id'].astype(str).str.replace(',', ''), errors='coerce').fillna(0).astype('int64')
            lender_df['principal_outstanding'] = pd.to_numeric(lender_df['principal_outstanding'], errors='coerce').fillna(0)
            lender_df['current_dpd'] = pd.to_numeric(lender_df['current_dpd'], errors='coerce').fillna(0)
            
            df.set_index('application_id', inplace=True)
            lender_df.set_index('app_id', inplace=True)
            
            # Update POS and DPD
            df.update(lender_df[['principal_outstanding', 'current_dpd']].rename(columns={'current_dpd': dpd_col}))
            df.reset_index(inplace=True)

        # Recalculate auxiliary fields specifically for LENDER cases (Ported from notebook cell 100)
        if lender_df is not None:
            mask = (df['application_id'].isin(lender_df.index))
            # Force conversion
            df['analysis_date'] = pd.to_datetime(df['analysis_date'], errors='coerce')
            df['first_emi_date'] = pd.to_datetime(df['first_emi_date'], errors='coerce')
            df['first_unpaid_emi_date'] = pd.to_datetime(df['first_unpaid_emi_date'], errors='coerce')

            # If DPD > 0, back-calculate unpaid emi date
            mask_positive = mask & (df[dpd_col] > 0)
            if any(mask_positive):
                df.loc[mask_positive, 'first_unpaid_emi_date'] = (
                    df.loc[mask_positive, 'analysis_date'] - 
                    pd.to_timedelta(df.loc[mask_positive, dpd_col].astype(float), unit='D')
                )
                
                # Recalculate count_of_emi_paid (month difference)
                df.loc[mask_positive, 'count_of_emi_paid'] = (
                    (df.loc[mask_positive, 'first_unpaid_emi_date'].dt.year - df.loc[mask_positive, 'first_emi_date'].dt.year) * 12 +
                    (df.loc[mask_positive, 'first_unpaid_emi_date'].dt.month - df.loc[mask_positive, 'first_emi_date'].dt.month)
                ).clip(lower=0)

        return df

    @staticmethod
    def clean_unpaid_dates(df: pd.DataFrame) -> pd.DataFrame:
        """Cleans first_unpaid_emi_date for ALL rows (Ported from notebook cell 14 & cell 99)."""
        df['analysis_date'] = pd.to_datetime(df['analysis_date'], errors='coerce')
        df['first_unpaid_emi_date'] = pd.to_datetime(df['first_unpaid_emi_date'], errors='coerce')
        
        dpd_col = 'current_month_revised_dpd' if 'current_month_revised_dpd' in df.columns else 'current_dpd'
        
        if dpd_col in df.columns:
            # Clear if DPD is 0 (except settled) - This fixes App ID 43
            mask_zero = (pd.to_numeric(df[dpd_col], errors='coerce').fillna(0) == 0) & (df['application_status'] != 'settled')
            df.loc[mask_zero, 'first_unpaid_emi_date'] = pd.NaT
        
        return df

    @staticmethod
    def apply_lender_name_changes(df: pd.DataFrame, lender_change_df: pd.DataFrame) -> pd.DataFrame:
        """Ported: Merges lender name changes from Excel."""
        df = pd.merge(df, lender_change_df, on='application_id', how='left')
        if 'lender_name_y' in df.columns:
            df['lender_name_x'] = df['lender_name_y'].fillna(df['lender_name_x'])
            df = df.rename(columns={'lender_name_x': 'lender_name'}).drop(columns='lender_name_y', axis=1)
        return df

    @staticmethod
    def handle_settlements(df: pd.DataFrame, settlement_df: pd.DataFrame, dpd_value: int = 365) -> pd.DataFrame:
        """Ported: Handles settlement logic from adjustments_data.xlsx sheet 1."""
        merchant_list = [
            "Pristyn Care (GHV)", "PRISTYN CARE - REIMBURSEMENT INSURANCE", "Klub Works Private Limited",
            "Hexa Health (Insurance Reimbursement)", "EasyAspataal (Insurance Reimbursement)",
            "Ayu Health (Insurance Reimbursement)", "PRISTYN CARE - BNPL", "EasyAspataal - BNPL",
            "CLAIMBUDDY (Insurance Reimbursement)"
        ]
        
        # Force int64 for comparison parity (handles potential commas)
        df['application_id'] = pd.to_numeric(df['application_id'].astype(str).str.replace(',', ''), errors='coerce').fillna(0).astype('int64')
        settlement_df['application_id'] = pd.to_numeric(settlement_df['application_id'].astype(str).str.replace(',', ''), errors='coerce').fillna(0).astype('int64')
        
        settlement_ids = settlement_df['application_id']
        settlement_dates = pd.to_datetime(settlement_df['settlement_date'])
        settlement_map = dict(zip(settlement_ids, settlement_dates))

        dpd_col = 'current_month_revised_dpd' if 'current_month_revised_dpd' in df.columns else 'current_dpd'

        mask = (
            (df['application_id'].isin(settlement_ids)) &
            (~df['application_status'].isin(['closed', 'cancelled'])) &
            (~df['merchant_name'].isin(merchant_list)) &
            (df['principal_outstanding'] != 0)
        )

        # Use .loc[mask, list] = value assignment style
        if any(mask):
            df.loc[mask, 'application_status'] = 'settled'
            df.loc[mask, dpd_col] = dpd_value
            df.loc[mask, 'loan_close_date'] = df.loc[mask, 'application_id'].map(settlement_map)
        
        return df

    @staticmethod
    def apply_final_cleansing(df: pd.DataFrame) -> pd.DataFrame:
        """Ported: Final POS zeroization rules and DPD bucketing."""
        
        dpd_col = 'current_month_revised_dpd' if 'current_month_revised_dpd' in df.columns else 'current_dpd'

        # 2. Merchants with full collection -> POS = 0, DPD = 0, Unpaid EMI Date = NaT
        merchant_list = [
            "Pristyn Care (GHV)", "PRISTYN CARE - REIMBURSEMENT INSURANCE", "Klub Works Private Limited",
            "Hexa Health (Insurance Reimbursement)", "EasyAspataal (Insurance Reimbursement)",
            "Ayu Health (Insurance Reimbursement)", "PRISTYN CARE - BNPL", "EasyAspataal - BNPL",
            "CLAIMBUDDY (Insurance Reimbursement)"
        ]
        mask_merchant = df['merchant_name'].isin(merchant_list)
        df.loc[mask_merchant, [dpd_col, 'principal_outstanding', 'first_unpaid_emi_date']] = [0, 0, pd.NaT]
        df.loc[mask_merchant, 'count_of_emi_paid'] = df.loc[mask_merchant, 'remaining_emi']
        df.loc[mask_merchant, 'total_principal_paid'] = df.loc[mask_merchant, 'emi_amount_expected']

        # 3. Pride Lender POS fix -> if DPD=0, POS=0 (Notebook line 825)
        mask_pride = (
            (df['lender_name'] == 'Pride Financial Services Private Limited') & 
            (pd.to_numeric(df[dpd_col], errors='coerce').fillna(0) == 0)
        )
        df.loc[mask_pride, 'principal_outstanding'] = 0
        
        # 4. If remaining_emi == count_of_emi_paid then pos = 0
        # Notebook line 842 / 500: return 0 if row['remaining_emi'] == row['count_of_emi_paid'] and row['principal_outstanding'] > 0 else row['principal_outstanding']
        mask_pos_zero = (df['remaining_emi'] == df['count_of_emi_paid']) & (df['principal_outstanding'] > 0)
        
        # Protect Capri and other external lenders from this heuristic if it contradicts leur specific override
        # We only apply it if lender name is NOT Capri Global Capital Limited
        mask_not_capri = df['lender_name'] != 'CAPRI GLOBAL CAPITAL LIMITED'
        df.loc[mask_pos_zero & mask_not_capri, 'principal_outstanding'] = 0

        # 6. Override: If POS is 0, all DPD variants must be 0
        mask_pos_zero_dpd = (df['principal_outstanding'] == 0)
        for col in ['current_dpd', 'current_month_original_dpd', 'current_month_revised_dpd']:
            if col in df.columns:
                df.loc[mask_pos_zero_dpd, col] = 0

        # 7. Re-bucket if DPD changed
        df[dpd_col] = pd.to_numeric(df[dpd_col], errors='coerce').fillna(0)
        conditions = [
            df[dpd_col] == 0,
            df[dpd_col] <= 30,
            df[dpd_col] <= 60,
            df[dpd_col] <= 90,
            df[dpd_col] <= 120,
            df[dpd_col] <= 150,
            df[dpd_col] <= 180,
            df[dpd_col] <= 364,
            df[dpd_col] >= 365
        ]
        labels = [
            "1. Current", "2. 1-30", "3. 31-60", "4. 61-90", "5. 91-120",
            "6. 121-150", "7. 151-180", "8. 181-364", "9. 365+"
        ]
        df['dpd_buckets'] = np.select(conditions, labels, default="Unknown")
        
        return df

    @staticmethod
    def handle_fraud(df: pd.DataFrame, fraud_ids: pd.Series) -> pd.DataFrame:
        """Ported: Pushes fraud cases with DPD > 0 to write off (365 DPD)."""
        df['application_id'] = df['application_id'].astype(float)
        fraud_ids = fraud_ids.astype(float)
        
        mask = (df['application_id'].isin(fraud_ids)) & (df['current_dpd'] > 0)
        df.loc[mask, 'current_dpd'] = 365
        return df

    @staticmethod
    def calculate_write_off(df: pd.DataFrame, fraud_ids: Optional[pd.Series] = None) -> pd.DataFrame:
        """Ported: Calculates Write Off flags and dates based on various conditions."""
        merchant_list = [
            "Pristyn Care (GHV)", "PRISTYN CARE - REIMBURSEMENT INSURANCE", "Klub Works Private Limited",
            "Hexa Health (Insurance Reimbursement)", "EasyAspataal (Insurance Reimbursement)",
            "Ayu Health (Insurance Reimbursement)", "PRISTYN CARE - BNPL", "EasyAspataal - BNPL",
            "CLAIMBUDDY (Insurance Reimbursement)"
        ]

        dpd_col = 'current_month_revised_dpd' if 'current_month_revised_dpd' in df.columns else 'current_dpd'

        # Normal write off (flag 1)
        df['write_off_flag'] = (df[dpd_col] > 364).astype(int)
        
        # Ensure dates are parsed
        df['first_unpaid_emi_date'] = pd.to_datetime(df['first_unpaid_emi_date'], errors='coerce')
        
        # Vectorized write_off_date calculation
        mask_wo = pd.notnull(df['first_unpaid_emi_date']) & (df[dpd_col] > 364)
        df['write_off_date'] = pd.NaT
        df.loc[mask_wo, 'write_off_date'] = (df.loc[mask_wo, 'first_unpaid_emi_date'] + pd.Timedelta(days=364)).dt.date

        # Merchant full collection (flag 0)
        df.loc[df['merchant_name'].isin(merchant_list), ['write_off_date', 'write_off_flag']] = [pd.NaT, 0]

        # Settlement cases (flag 2)
        df.loc[df['application_status'] == 'settled', ['write_off_date', 'write_off_flag']] = [pd.NaT, 2]

        # Fraud cases (flag 3)
        if fraud_ids is not None:
            mask = (df['application_id'].isin(fraud_ids)) & (df[dpd_col] > 364)
            df.loc[mask, ['write_off_date', 'write_off_flag']] = [datetime.strptime("31-03-2025", "%d-%m-%Y").date(), 3]

        # Specific workaround for app_id 236220
        # This ID needs 365 DPD in both Original and Revised if it's there
        mask_236220 = (df['application_id'] == 236220)
        if any(mask_236220):
            df.loc[mask_236220, ['write_off_date', 'write_off_flag', dpd_col, 'dpd_buckets']] = [
                datetime.strptime("31-03-2025", "%d-%m-%Y").date(), 1, 365, "9. 365+"
            ]
            if 'current_month_original_dpd' in df.columns:
                df.loc[mask_236220, 'current_month_original_dpd'] = 365
        
        # Override: If principal_outstanding == 0, write_off_flag MUST be 0
        df.loc[df['principal_outstanding'] == 0, 'write_off_flag'] = 0
        df.loc[df['principal_outstanding'] == 0, 'write_off_date'] = pd.NaT

        return df

    @staticmethod
    def calculate_merchant_adjustments(df: pd.DataFrame, additional_adj_df: pd.DataFrame) -> pd.DataFrame:
        """Sums system hold amounts and additional adjustments per merchant (Notebook cell 83-85)."""
        hold_df = df.groupby(['merchant_name']).agg({'hold_amount':'sum'}).reset_index()
        hold_df = hold_df.rename(columns={'hold_amount':'hold_amount_from_system'})

        merchant_wise_adj = pd.merge(hold_df, additional_adj_df, on='merchant_name', how='left')
        
        cols = ["hold_amount_from_system", "additional_adjustment_amount"]
        for col in cols:
            if col in merchant_wise_adj.columns:
                merchant_wise_adj[col] = pd.to_numeric(merchant_wise_adj[col], errors='coerce').fillna(0).round()
            else:
                merchant_wise_adj[col] = 0.0

        merchant_wise_adj['total_adjustment_amount'] = merchant_wise_adj['hold_amount_from_system'] + merchant_wise_adj['additional_adjustment_amount']
        return merchant_wise_adj

    @staticmethod
    def apply_waterfall_allocation(df: pd.DataFrame, merchant_wise_adj: pd.DataFrame, exclusion_df: pd.DataFrame) -> pd.DataFrame:
        """Distributes merchant funds to eligible 90+ DPD cases (Waterfall) (Notebook cell 86-88)."""
        try:
            from src.application.services.merchant_risk.constants import WRITE_OFF_CASES_MAR23
        except ImportError:
            WRITE_OFF_CASES_MAR23 = []
            
        dpd_col = 'current_month_original_dpd' if 'current_month_original_dpd' in df.columns else 'current_dpd'

        mask_eligible = (
            (df[dpd_col] > 90) &
            (~df.get('write_off_flag', pd.Series(0, index=df.index)).isin([2, 3])) &
            (~df['application_id'].isin(exclusion_df['application_id'])) &
            (~df['application_id'].isin(WRITE_OFF_CASES_MAR23))
        )
        
        pool = df[mask_eligible].copy()
        if pool.empty:
            df['amt_to_be_adjusted'] = 0.0
            df['date_of_adjustment'] = pd.NaT
            df['adjustment_type'] = np.nan
            return df

        # Use first_unpaid_emi_date + 89 days as per notebook cell 87 (line 179)
        pool['first_unpaid_emi_date'] = pd.to_datetime(pool['first_unpaid_emi_date'], errors='coerce')
        pool['date_of_adjustment'] = pool['first_unpaid_emi_date'] + pd.Timedelta(days=89)
        
        # Sort as per notebook cell 87 (line 180)
        pool.sort_values(['merchant_name', dpd_col, 'principal_outstanding'], 
                        ascending=[True, False, True], inplace=True)
        
        pool = pd.merge(pool, merchant_wise_adj[['merchant_name', 'total_adjustment_amount']], on='merchant_name', how='left')
        pool['total_adjustment_amount'] = pool['total_adjustment_amount'].fillna(0)

        def allocate(group):
            remaining_hold = group['total_adjustment_amount'].iloc[0]
            adjustments = []
            for _, row in group.iterrows():
                if remaining_hold <= 0:
                    adj = 0.0
                elif remaining_hold >= row['principal_outstanding']:
                    adj = row['principal_outstanding']
                    remaining_hold -= adj
                else:
                    adj = remaining_hold
                    remaining_hold = 0.0
                adjustments.append(adj)
            group['amt_to_be_adjusted'] = adjustments
            return group

        final_pool = pool.groupby('merchant_name', group_keys=False).apply(allocate)
        final_pool = final_pool[final_pool['amt_to_be_adjusted'] > 0].copy()
        final_pool['adjustment_type'] = 'fldg/hold'

        df = pd.merge(df, 
                     final_pool[['application_id', 'date_of_adjustment', 'amt_to_be_adjusted', 'adjustment_type']], 
                     on='application_id', how='left')
        
        df['amt_to_be_adjusted'] = df['amt_to_be_adjusted'].fillna(0.0)
        return df

    @staticmethod
    def pos_calculation_after_adj(row: pd.Series) -> pd.Series:
        """Optimized POS calculation factored in amt_to_be_adjusted using Numba JIT."""
        emi_dict = row.get('emi_date_wise_amount_list', {})
        if not isinstance(emi_dict, dict) or not emi_dict:
            return pd.Series([0.0, 0.0, 0.0, 0.0])

        if row['application_status'] in ['closed', 'cancelled']:
            return pd.Series([0.0, float(row.get('pre_part_payment_amount', 0)), 0.0, 0.0])

        # Pre-process EMI list
        try:
            sorted_amounts = np.array([float(v) for _, v in sorted(emi_dict.items(), key=lambda x: x[0][-4:] + x[0][3:5] + x[0][0:2])], dtype=np.float64)
        except:
            return pd.Series([0.0, 0.0, 0.0, 0.0])
            
        res = _jit_risk_engine(
            float(row['loan_principal']),
            float(row['irr_rate'] / 100),
            float(row['amount_paid_for_emi']),
            sorted_amounts,
            float(row['pre_part_payment_amount']),
            float(row['amt_to_be_adjusted']),
            True # After Adjustment logic
        )
        return pd.Series(res)

    @staticmethod
    def calculate_revised_dpd(df: pd.DataFrame, previous_df: pd.DataFrame) -> pd.DataFrame:
        """Applies Rolling DPD logic (Notebook cell 108) using vectorized operations."""
        df = df.merge(previous_df[['application_id', 'previous_month_dpd', 'previous_month_pos', 'previous_write_off_date']], 
                     on='application_id', how='left')
        df['previous_month_dpd'] = df['previous_month_dpd'].fillna(0)
        
        # Initial copy
        df['current_month_revised_dpd'] = df['current_month_original_dpd']
        
        # Define masks for rolling forward
        finished_status = ["settled", "closed", "cancelled"]
        mask_roll_365 = (df['previous_month_dpd'] >= 365) & (~df['application_status'].isin(finished_status))
        mask_roll_91 = (df['previous_month_dpd'] >= 91) & (df['current_month_original_dpd'] > 0) & (df['current_month_original_dpd'] < 91) & (~df['application_status'].isin(finished_status))
        
        # Apply logic
        df.loc[mask_roll_365,'current_month_revised_dpd'] = df.loc[mask_roll_365,['previous_month_dpd', 'current_month_original_dpd']].max(axis=1)
        df.loc[mask_roll_91, 'current_month_revised_dpd'] = df.loc[mask_roll_91, 'previous_month_dpd']
        
        return df

    @staticmethod
    def calculate_writeback(df: pd.DataFrame) -> pd.DataFrame:
        """Calculates writeback amount for 365+ DPD cases (Notebook cell 110) vectorized."""
        df['writeback_amount'] = 0.0
        finished_status = ["settled", "closed", "cancelled"]
        mask_wb = (df['previous_month_dpd'] >= 365) & (~df['application_status'].isin(finished_status))
        
        df.loc[mask_wb, 'writeback_amount'] = (
            df.loc[mask_wb, 'previous_month_pos'].astype(float) - 
            df.loc[mask_wb, 'principal_outstanding'].astype(float)
        ).clip(lower=0)
        
        return df
