# -*- coding: utf-8 -*-
# AI_CHANGELOG [2026-02-13] 修复 STOP_HIGH_BALANCE 会员不参与评分的逻辑缺陷；
#   Prompt: "STOP_HIGH_BALANCE 应该参与 WBI 评分"
"""
老客挽回指数（WBI）计算任务。"""
from __future__ import annotations

import math
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple

from .member_index_base import MemberActivityData, MemberIndexBaseTask
from ..base_dws_task import TaskContext


@dataclass
class MemberWinbackData:
    activity: MemberActivityData
    status: str
    segment: str

    overdue_old: float = 0.0
    overdue_cdf_p: float = 0.0
    drop_old: float = 0.0
    recharge_old: float = 0.0
    value_old: float = 0.0
    ideal_interval_days: Optional[float] = None
    ideal_next_visit_date: Optional[date] = None

    raw_score: Optional[float] = None
    display_score: Optional[float] = None


class WinbackIndexTask(MemberIndexBaseTask):
    """老客挽回指数（WBI）计算任务。"""

    INDEX_TYPE = "WBI"

    DEFAULT_PARAMS = {
        # 通用参数
        'lookback_days_recency': 60,
        'visit_lookback_days': 180,
        'percentile_lower': 5,
        'percentile_upper': 95,
        'compression_mode': 0,
        'use_smoothing': 1,
        'ewma_alpha': 0.2,
        # 分流参数
        'new_visit_threshold': 2,
        'new_days_threshold': 30,
        'recharge_recent_days': 14,
        'new_recharge_max_visits': 10,
        'recency_hard_floor_days': 14,
        'recency_gate_days': 14,
        'recency_gate_slope_days': 3,
        # WBI参数
        'overdue_alpha': 2.0,
        'overdue_weight_halflife_days': 30,
        'overdue_weight_blend_min_samples': 8,
        'h_recharge': 7,
        'amount_base_M0': 300,
        'balance_base_B0': 500,
        'value_w_spend': 1.0,
        'value_w_bal': 1.0,
        'w_over': 2.0,
        'w_drop': 1.0,
        'w_re': 0.4,
        'w_value': 1.2,
        # STOP高余额例外（默认关闭）
        'enable_stop_high_balance_exception': 0,
        'high_balance_threshold': 1000,
    }

    def get_task_code(self) -> str:
        return "DWS_WINBACK_INDEX"

    def get_target_table(self) -> str:
        return "dws_member_winback_index"

    def get_primary_keys(self) -> List[str]:
        return ['site_id', 'member_id']

    def get_index_type(self) -> str:
        return self.INDEX_TYPE

    # CHANGE 2026-07-25 | intent: 迁移至 MemberIndexBaseTask 模板方法
    # 原 execute() 拆分为 _calculate_scores() + _save_results()，
    # 由基类 execute() 统一编排。

    def _calculate_scores(
        self,
        activities: Dict[int, MemberActivityData],
        params: Dict[str, float],
        site_id: int,
        tenant_id: int,
    ) -> List[MemberWinbackData]:
        """对会员活动数据计算 WBI 原始评分。

        Returns:
            评分后的 MemberWinbackData 列表
        """
        winback_list: List[MemberWinbackData] = []
        for activity in activities.values():
            segment, status, in_scope = self.classify_segment(activity, params)
            if not in_scope:
                continue

            if segment != "OLD" and status != "STOP_HIGH_BALANCE":
                continue

            data = MemberWinbackData(activity=activity, status=status, segment=segment)

            # CHANGE 2026-02-13 | intent: STOP_HIGH_BALANCE 也参与评分
            if segment == "OLD" or status == "STOP_HIGH_BALANCE":
                self._calculate_wbi_scores(data, params)
            winback_list.append(data)

        return winback_list

    def _save_results(
        self,
        raw_scores: List[MemberWinbackData],
        site_id: int,
        tenant_id: int,
        params: Dict[str, float],
        context: Optional[TaskContext],
    ) -> Dict[str, Any]:
        """归一化 WBI 评分并持久化到数据库。"""
        winback_list = raw_scores

        if not winback_list:
            self.logger.warning("No old-member rows to calculate")
            return {'status': 'skipped', 'reason': 'no_old_members'}

        # 归一化 Display Score
        score_pairs = [
            (d.activity.member_id, d.raw_score)
            for d in winback_list
            if d.raw_score is not None
        ]
        if score_pairs:
            compression = self._map_compression(params)
            use_smoothing = int(params.get('use_smoothing', 1)) == 1
            normalized = self.batch_normalize_to_display(
                score_pairs,
                compression=compression,
                percentile_lower=int(params['percentile_lower']),
                percentile_upper=int(params['percentile_upper']),
                use_smoothing=use_smoothing,
                site_id=site_id
            )
            score_map = {member_id: display for member_id, _, display in normalized}
            for data in winback_list:
                if data.activity.member_id in score_map:
                    data.display_score = score_map[data.activity.member_id]

            # 保存分位点历史
            all_raw = [float(score) for _, score in score_pairs]
            q_l, q_u = self.calculate_percentiles(
                all_raw,
                int(params['percentile_lower']),
                int(params['percentile_upper'])
            )
            if use_smoothing:
                smoothed_l, smoothed_u = self._apply_ewma_smoothing(site_id, q_l, q_u)
            else:
                smoothed_l, smoothed_u = q_l, q_u
            self.save_percentile_history(
                site_id=site_id,
                percentile_5=q_l,
                percentile_95=q_u,
                percentile_5_smoothed=smoothed_l,
                percentile_95_smoothed=smoothed_u,
                record_count=len(all_raw),
                min_raw=min(all_raw),
                max_raw=max(all_raw),
                avg_raw=sum(all_raw) / len(all_raw)
            )

        # 日快照模式：始终按 stat_date 写入
        now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
        stat_date = now.date() if hasattr(now, 'date') else now
        inserted = self._save_winback_data(winback_list, stat_date=stat_date)
        self.logger.info("WBI calculation finished, inserted %d rows", inserted)

        return {
            'status': 'success',
            'member_count': len(winback_list),
            'records_inserted': inserted
        }

    def _weighted_cdf(
        self,
        samples: List[Tuple[float, int]],
        t_v: float,
        halflife_days: float,
        blend_min_samples: int,
    ) -> float:
        if not samples:
            return 0.5

        if halflife_days <= 0:
            p_equal = sum(1.0 for interval, _ in samples if interval <= t_v) / len(samples)
            return self._clip(p_equal, 0.0, 1.0)

        ln2 = math.log(2.0)
        weighted_hit = 0.0
        weight_sum = 0.0
        equal_hit = 0.0
        for interval, age_days in samples:
            weight = math.exp(-ln2 * float(age_days) / halflife_days)
            indicator = 1.0 if interval <= t_v else 0.0
            weighted_hit += weight * indicator
            weight_sum += weight
            equal_hit += indicator

        p_weighted = 0.5 if weight_sum <= 0 else (weighted_hit / weight_sum)
        p_equal = equal_hit / len(samples)
        lam = min(1.0, float(len(samples)) / float(max(1, blend_min_samples)))
        p_final = lam * p_weighted + (1.0 - lam) * p_equal
        return self._clip(p_final, 0.0, 1.0)

    def _weighted_quantile(
        self,
        samples: List[Tuple[float, int]],
        quantile: float,
        halflife_days: float,
        blend_min_samples: int,
    ) -> Optional[float]:
        if not samples:
            return None

        q = self._clip(quantile, 0.0, 1.0)
        equal_weight = 1.0 / float(len(samples))
        if halflife_days <= 0:
            weighted = [(interval, equal_weight) for interval, _ in samples]
        else:
            ln2 = math.log(2.0)
            raw_weighted: List[Tuple[float, float]] = []
            total = 0.0
            for interval, age_days in samples:
                w = math.exp(-ln2 * float(age_days) / halflife_days)
                raw_weighted.append((interval, w))
                total += w
            if total <= 0:
                weighted = [(interval, equal_weight) for interval, _ in samples]
            else:
                weighted = [(interval, w / total) for interval, w in raw_weighted]

        # 对小样本混合加权分布与等权分布。
        lam = min(1.0, float(len(samples)) / float(max(1, blend_min_samples)))
        blended: List[Tuple[float, float]] = []
        for (interval_w, w), (interval_e, _) in zip(weighted, samples):
            _ = interval_e  # keep tuple alignment explicit
            blended_weight = lam * w + (1.0 - lam) * equal_weight
            blended.append((interval_w, blended_weight))

        blended.sort(key=lambda item: item[0])
        cumulative = 0.0
        for interval, weight in blended:
            cumulative += weight
            if cumulative >= q:
                return float(interval)
        return float(blended[-1][0])

    def _calculate_wbi_scores(self, data: MemberWinbackData, params: Dict[str, float]) -> None:
        """计算 WBI 分项与 Raw Score"""
        activity = data.activity

        # 1) 超期紧急性（基于近期加权经验CDF）
        overdue_alpha = float(params['overdue_alpha'])
        half_life_days = float(params.get('overdue_weight_halflife_days', 30))
        blend_min_samples = int(params.get('overdue_weight_blend_min_samples', 8))
        if activity.interval_count <= 0:
            p = 0.5
            ideal_interval = None
        else:
            if len(activity.interval_ages_days) == activity.interval_count:
                samples = list(zip(activity.intervals, activity.interval_ages_days))
            else:
                samples = [(interval, 0) for interval in activity.intervals]
            p = self._weighted_cdf(
                samples=samples,
                t_v=activity.t_v,
                halflife_days=half_life_days,
                blend_min_samples=blend_min_samples,
            )
            ideal_interval = self._weighted_quantile(
                samples=samples,
                quantile=0.5,
                halflife_days=half_life_days,
                blend_min_samples=blend_min_samples,
            )
        data.overdue_cdf_p = p
        data.overdue_old = math.pow(p, overdue_alpha)
        data.ideal_interval_days = ideal_interval
        if ideal_interval is not None and activity.last_visit_time is not None:
            ideal_days = max(0, int(round(ideal_interval)))
            data.ideal_next_visit_date = activity.last_visit_time.date() + timedelta(days=ideal_days)
        else:
            data.ideal_next_visit_date = None

        # 2) 降频分
        expected14 = activity.visits_60d * 14.0 / 60.0
        data.drop_old = self._clip((expected14 - activity.visits_14d) / (expected14 + 1), 0.0, 1.0)

        # 3) 充值未回访压力
        if activity.recharge_unconsumed == 1:
            data.recharge_old = self.decay(activity.t_r, params['h_recharge'])
        else:
            data.recharge_old = 0.0

        # 4) 价值分
        m0 = float(params['amount_base_M0'])
        b0 = float(params['balance_base_B0'])
        spend_score = math.log1p(activity.spend_180d / m0) if m0 > 0 else 0.0
        bal_score = math.log1p(activity.sv_balance / b0) if b0 > 0 else 0.0
        data.value_old = float(params['value_w_spend']) * spend_score + float(params['value_w_bal']) * bal_score

        data.raw_score = (
            float(params['w_over']) * data.overdue_old
            + float(params['w_drop']) * data.drop_old
            + float(params['w_re']) * data.recharge_old
            + float(params['w_value']) * data.value_old
        )

        hard_floor_days = float(params.get('recency_hard_floor_days', 0))
        gate_days = float(params.get('recency_gate_days', 14))
        slope_days = float(params.get('recency_gate_slope_days', 3))
        if hard_floor_days > 0 and activity.t_v < hard_floor_days:
            suppression = 0.0
        elif slope_days <= 0:
            suppression = 1.0 if activity.t_v >= gate_days else 0.0
        else:
            x = (activity.t_v - gate_days) / slope_days
            x = self._clip(x, -60.0, 60.0)
            suppression = 1.0 / (1.0 + math.exp(-x))
        data.raw_score *= suppression

        # 限制在 0 以上
        if data.raw_score < 0:
            data.raw_score = 0.0

    def _save_winback_data(self, data_list: List[MemberWinbackData], *, stat_date) -> int:
        """日快照模式：按 (site_id, stat_date) 删除后插入。"""
        if not data_list:
            return 0

        from datetime import date as date_type
        if not isinstance(stat_date, date_type):
            stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date

        site_id = data_list[0].activity.site_id
        with self.db.conn.cursor() as cur:
            cur.execute(
                "DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date = %s",
                (site_id, stat_date),
            )

        insert_sql = """
            INSERT INTO dws.dws_member_winback_index (
                site_id, tenant_id, member_id,
                status, segment,
                member_create_time, first_visit_time, last_visit_time, last_recharge_time,
                t_v, t_r, t_a,
                visits_14d, visits_30d, visits_60d, visits_total,
                spend_30d, spend_180d, sv_balance, recharge_60d_amt,
                interval_count,
                overdue_old, overdue_cdf_p, drop_old, recharge_old, value_old,
                ideal_interval_days, ideal_next_visit_date,
                raw_score, display_score,
                last_wechat_touch_time,
                calc_time, created_at, updated_at, stat_date
            ) VALUES (
                %s, %s, %s,
                %s, %s,
                %s, %s, %s, %s,
                %s, %s, %s,
                %s, %s, %s, %s,
                %s, %s, %s, %s,
                %s,
                %s, %s, %s, %s, %s,
                %s, %s,
                %s, %s,
                %s,
                NOW(), NOW(), NOW(), %s
            )
        """

        # 批量写入（executemany 替代逐行 execute）
        batch_params = []
        for data in data_list:
            activity = data.activity
            batch_params.append((
                activity.site_id, activity.tenant_id, activity.member_id,
                data.status, data.segment,
                activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
                activity.t_v, activity.t_r, activity.t_a,
                activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
                activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
                activity.interval_count,
                data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
                data.ideal_interval_days, data.ideal_next_visit_date,
                data.raw_score, data.display_score,
                None,
                stat_date,
            ))

        from psycopg2.extras import execute_batch
        with self.db.conn.cursor() as cur:
            execute_batch(cur, insert_sql, batch_params, page_size=200)
            inserted = len(batch_params)

            # 保留策略：清理 365 天前的快照
            cur.execute(
                "DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
                (site_id,),
            )

        self.db.conn.commit()
        return inserted

    def _clip(self, value: float, low: float, high: float) -> float:
        return max(low, min(high, value))

    def _map_compression(self, params: Dict[str, float]) -> str:
        mode = int(params.get('compression_mode', 0))
        if mode == 1:
            return "log1p"
        if mode == 2:
            return "asinh"
        return "none"


__all__ = ['WinbackIndexTask']

