# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
#   get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
#   改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | build_performance_summary 中 tier_nodes
#   从 cfg_performance_tier 配置表读取（不再依赖 salary_calc 的空列表），
#   next_tier_hours/tier_completed 根据 effective_hours 和 tier_nodes 实时计算。
# - 2026-03-24 | Prompt: bonus_money 公式修正 | bonus_money 改为基础课节省 + 打赏课节省：
#   基础课 = next_tier_min_hours × (当前档 base_deduction - 下一档 base_deduction)；
#   打赏课 = bonus_hours × incentive_rate × (当前档 bonus_deduction_ratio - 下一档)。
# - 2026-03-25 | Prompt: 保底 relationship_building 任务 | get_task_list_v2() 中新增 SQL 层面
#   排除 RS 范围外的 relationship_building 任务（Step 0 预查 ETL RS 排除列表 → COUNT/分页
#   查询加 NOT (task_type='relationship_building' AND member_id=ANY(exclude)) 条件），
#   替代原内存过滤方案，修复跨页 total 不准确问题。
# - 2026-03-25 | Prompt: 绩效页→任务详情页按 member_id 查询 | 新增 get_task_by_member()，
#   按 (assistant_id, member_id, site_id, status='active') 查询，多条时取优先级最高的一条，
#   复用 get_task_detail() 返回完整详情。
# - 2026-03-25 | Prompt: 任务详情服务记录6项改进 | get_task_detail() 改造：
#   (1) 统计范围改为近60天（列表不限）；(2) 预估规则：当月且日期≤5号；
#   (3) AI 文案从 ai_analysis.summary 传到前端（不再硬编码）；
#   (4) drinks 字段透传到 service_records。

"""
任务管理服务

负责任务 CRUD、置顶、放弃、取消放弃等操作。
直连 ETL 库查询 app.v_* RLS 视图获取客户信息和 RS 指数，计算爱心 icon 档位。

RNS1.1 扩展：get_task_list_v2（TASK-1）、get_task_detail（TASK-2）。
"""

import json
import logging
from datetime import datetime
from decimal import Decimal

from fastapi import HTTPException

from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter
from app.services.task_generator import compute_heart_icon
from app.trace.decorators import trace_service

logger = logging.getLogger(__name__)


def _get_connection():
    """延迟导入 get_connection，避免纯函数测试时触发模块级导入失败。"""
    from app.database import get_connection

    return get_connection()


def _record_history(
    cur,
    task_id: int,
    action: str,
    old_status: str | None = None,
    new_status: str | None = None,
    old_task_type: str | None = None,
    new_task_type: str | None = None,
    detail: dict | None = None,
) -> None:
    """在 coach_task_history 中记录变更。"""
    cur.execute(
        """
        INSERT INTO biz.coach_task_history
            (task_id, action, old_status, new_status,
             old_task_type, new_task_type, detail)
        VALUES (%s, %s, %s, %s, %s, %s, %s)
        """,
        (
            task_id,
            action,
            old_status,
            new_status,
            old_task_type,
            new_task_type,
            json.dumps(detail) if detail else None,
        ),
    )


def _get_assistant_id(conn, user_id: int, site_id: int) -> int:
    """
    通过 user_assistant_binding 获取 assistant_id。

    找不到绑定关系时抛出 403。
    """
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT assistant_id
            FROM auth.user_assistant_binding
            WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL
              AND is_removed = false
            ORDER BY id DESC
            LIMIT 1
            """,
            (user_id, site_id),
        )
        row = cur.fetchone()
    if not row:
        raise HTTPException(status_code=403, detail="权限不足")
    return row[0]


def _verify_task_ownership(
    conn, task_id: int, assistant_id: int, site_id: int, required_status: str | None = None
) -> dict:
    """
    验证任务归属并返回任务信息。

    - 任务不存在 → 404
    - 不属于当前助教 → 403
    - required_status 不匹配 → 409
    """
    runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
    with conn.cursor() as cur:
        cur.execute(
            f"""
            SELECT id, task_type, status, is_pinned, abandon_reason,
                   assistant_id, site_id
            FROM biz.coach_tasks
            WHERE id = %s
            {runtime_clause}
            """,
            [task_id, *runtime_params],
        )
        row = cur.fetchone()

    if not row:
        raise HTTPException(status_code=404, detail="资源不存在")

    task = {
        "id": row[0],
        "task_type": row[1],
        "status": row[2],
        "is_pinned": row[3],
        "abandon_reason": row[4],
        "assistant_id": row[5],
        "site_id": row[6],
    }

    if task["site_id"] != site_id or task["assistant_id"] != assistant_id:
        raise HTTPException(status_code=403, detail="权限不足")

    if required_status and task["status"] != required_status:
        raise HTTPException(status_code=409, detail="任务状态不允许此操作")

    return task


@trace_service("获取任务列表", "Get task list")
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
    """
    获取助教的任务列表（含有效 + 已放弃）。

    1. 通过 auth.user_assistant_binding 获取 assistant_id
    2. 查询 biz.coach_tasks WHERE status IN ('active', 'abandoned')
    3. 通过 FDW 读取客户基本信息（dim_member）和 RS 指数
    4. 计算爱心 icon 档位
    5. 排序：abandoned 排最后 → is_pinned DESC → priority_score DESC → created_at ASC

    FDW 查询需要 SET LOCAL app.current_site_id。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)

        # 查询有效 + 已放弃任务（abandoned 排最后）
        runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT id, task_type, status, priority_score, is_pinned,
                       expires_at, created_at, member_id, abandon_reason
                FROM biz.coach_tasks
                WHERE site_id = %s
                  AND assistant_id = %s
                  AND status IN ('active', 'abandoned')
                  {runtime_clause}
                ORDER BY
                    CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
                    is_pinned DESC,
                    priority_score DESC NULLS LAST,
                    created_at ASC
                """,
                [site_id, assistant_id, *runtime_params],
            )
            tasks = cur.fetchall()
        conn.commit()

        if not tasks:
            return []

        member_ids = list({t[7] for t in tasks})

        # 通过 FDW 读取客户信息和 RS 指数（需要 SET LOCAL app.current_site_id）
        member_info_map: dict[int, dict] = {}
        rs_map: dict[int, Decimal] = {}

        # CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dim_member + v_dws_member_assistant_relation_index
        # → 直连 ETL 库查 app.v_* RLS 视图
        # intent: 修复 RLS 门店隔离失效（postgres_fdw 不传递 GUC 参数）
        from app.services.fdw_queries import _fdw_context

        with _fdw_context(conn, site_id) as cur:
            # 读取客户基本信息
            # 列名映射: FDW 外部表 member_name/member_phone → RLS 视图 nickname/mobile
            cur.execute(
                """
                SELECT member_id, nickname, mobile
                FROM app.v_dim_member
                WHERE member_id = ANY(%s)
                """,
                (member_ids,),
            )
            for row in cur.fetchall():
                member_info_map[row[0]] = {
                    "member_name": row[1],
                    "member_phone": row[2],
                }

            # 读取 RS 指数
            cur.execute(
                """
                SELECT member_id, COALESCE(rs_display, 0)
                FROM app.v_dws_member_assistant_relation_index
                WHERE assistant_id = %s
                  AND member_id = ANY(%s)
                """,
                (assistant_id, member_ids),
            )
            for row in cur.fetchall():
                rs_map[row[0]] = Decimal(str(row[1]))

        # 组装结果
        result = []
        for task_row in tasks:
            (task_id, task_type, status, priority_score,
             is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row

            info = member_info_map.get(member_id, {})
            rs_score = rs_map.get(member_id, Decimal("0"))
            heart_icon = compute_heart_icon(rs_score)

            result.append({
                "id": task_id,
                "task_type": task_type,
                "status": status,
                "priority_score": float(priority_score) if priority_score else None,
                "is_pinned": is_pinned,
                "expires_at": expires_at.isoformat() if expires_at else None,
                "created_at": created_at.isoformat() if created_at else None,
                "member_id": member_id,
                "member_name": info.get("member_name"),
                "member_phone": info.get("member_phone"),
                "rs_score": float(rs_score),
                "heart_icon": heart_icon,
                "abandon_reason": abandon_reason,
            })

        return result

    finally:
        conn.close()


@trace_service("置顶任务", "Pin task")
async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
    """
    置顶任务。

    验证任务归属后设置 is_pinned=TRUE，记录 history。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)
        task = _verify_task_ownership(
            conn, task_id, assistant_id, site_id, required_status="active"
        )

        with conn.cursor() as cur:
            cur.execute("BEGIN")
            cur.execute(
                """
                UPDATE biz.coach_tasks
                SET is_pinned = TRUE, updated_at = NOW()
                WHERE id = %s
                """,
                (task_id,),
            )
            _record_history(
                cur,
                task_id,
                action="pin",
                old_status="active",
                new_status="active",
                old_task_type=task["task_type"],
                new_task_type=task["task_type"],
                detail={"is_pinned": True},
            )
        conn.commit()

        return {"id": task_id, "is_pinned": True}

    finally:
        conn.close()


@trace_service("取消置顶", "Unpin task")
async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
    """
    取消置顶。

    验证任务归属后设置 is_pinned=FALSE。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)
        task = _verify_task_ownership(
            conn, task_id, assistant_id, site_id, required_status="active"
        )

        with conn.cursor() as cur:
            cur.execute("BEGIN")
            cur.execute(
                """
                UPDATE biz.coach_tasks
                SET is_pinned = FALSE, updated_at = NOW()
                WHERE id = %s
                """,
                (task_id,),
            )
        conn.commit()

        return {"id": task_id, "is_pinned": False}

    finally:
        conn.close()


@trace_service("放弃任务", "Abandon task")
async def abandon_task(
    task_id: int, user_id: int, site_id: int, reason: str
) -> dict:
    """
    放弃任务。

    1. 验证 reason 非空（空或纯空白 → 422）
    2. 验证任务归属和 status='active'
    3. 设置 status='abandoned', abandon_reason=reason
    4. 记录 coach_task_history
    """
    if not reason or not reason.strip():
        raise HTTPException(status_code=422, detail="放弃原因不能为空")

    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)
        task = _verify_task_ownership(
            conn, task_id, assistant_id, site_id, required_status="active"
        )

        with conn.cursor() as cur:
            cur.execute("BEGIN")
            cur.execute(
                """
                UPDATE biz.coach_tasks
                SET status = 'abandoned',
                    abandon_reason = %s,
                    updated_at = NOW()
                WHERE id = %s
                """,
                (reason, task_id),
            )
            _record_history(
                cur,
                task_id,
                action="abandon",
                old_status="active",
                new_status="abandoned",
                old_task_type=task["task_type"],
                new_task_type=task["task_type"],
                detail={"abandon_reason": reason},
            )
        conn.commit()

        return {"id": task_id, "status": "abandoned"}

    finally:
        conn.close()


@trace_service("取消放弃", "Cancel abandon")
async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
    """
    取消放弃。

    1. 验证任务归属和 status='abandoned'
    2. 恢复 status='active', 清空 abandon_reason
    3. 记录 coach_task_history
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)
        task = _verify_task_ownership(
            conn, task_id, assistant_id, site_id, required_status="abandoned"
        )

        with conn.cursor() as cur:
            cur.execute("BEGIN")
            cur.execute(
                """
                UPDATE biz.coach_tasks
                SET status = 'active',
                    is_pinned = FALSE,
                    abandon_reason = NULL,
                    updated_at = NOW()
                WHERE id = %s
                """,
                (task_id,),
            )
            _record_history(
                cur,
                task_id,
                action="cancel_abandon",
                old_status="abandoned",
                new_status="active",
                old_task_type=task["task_type"],
                new_task_type=task["task_type"],
            )
        conn.commit()

        return {"id": task_id, "status": "active", "is_pinned": False}

    finally:
        conn.close()


# ---------------------------------------------------------------------------
# RNS1.1 扩展：辅助常量与工具函数
# ---------------------------------------------------------------------------

# 任务类型 → 中文标签
_TASK_TYPE_LABEL_MAP: dict[str, str] = {
    "high_priority_recall": "高优先召回",
    "priority_recall": "优先召回",
    "follow_up_visit": "客户回访",
    "relationship_building": "关系构建",
}

# 课程类型 → courseTypeClass 枚举映射（design.md 定义）
_COURSE_TYPE_CLASS_MAP: dict[str, str] = {
    "basic": "basic",
    "陪打": "basic",
    "基础课": "basic",
    "vip": "vip",
    "包厢": "vip",
    "包厢课": "vip",
    "tip": "tip",
    "超休": "tip",
    "激励课": "tip",
    "recharge": "recharge",
    "充值": "recharge",
    "incentive": "incentive",
    "激励": "incentive",
}

# 维客线索 category → tag_color 映射
# CHANGE 2026-03-24 | 值改为前端 clue-card 组件 CSS 类名后缀（primary/success/...），
# 不再用十六进制颜色——WXSS 类名 `clue-tag-#0052d9` 无效。
_CATEGORY_COLOR_MAP: dict[str, str] = {
    "客户基础": "primary",
    "客户基础信息": "primary",
    "消费习惯": "error",
    "玩法偏好": "success",
    "促销偏好": "orange",
    "促销接受": "orange",
    "社交关系": "purple",
    "重要反馈": "error",
}


@trace_service(description_zh="map_course_type_class", description_en="Map Course Type Class")
def map_course_type_class(raw_course_type: str | None) -> str:
    """将原始课程类型映射为统一枚举值（不带 tag- 前缀）。"""
    if not raw_course_type:
        return "basic"
    return _COURSE_TYPE_CLASS_MAP.get(raw_course_type.strip(), "basic")


@trace_service(description_zh="compute_income_trend", description_en="Compute Income Trend")
def compute_income_trend(current_income: float, prev_income: float) -> tuple[str, str]:
    """
    计算收入趋势。

    返回 (income_trend, income_trend_dir)。
    如 (1000, 800) → ("↑200", "up")
    """
    diff = current_income - prev_income
    direction = "up" if diff >= 0 else "down"
    arrow = "↑" if diff >= 0 else "↓"
    trend = f"{arrow}{abs(diff):.0f}"
    return trend, direction


@trace_service(description_zh="sanitize_tag", description_en="Sanitize Tag")
def sanitize_tag(raw_tag: str | None) -> str:
    """去除 tag 中的换行符，多行标签使用空格分隔。"""
    if not raw_tag:
        return ""
    return raw_tag.replace("\n", " ").strip()


def _extract_emoji_and_text(summary: str | None) -> tuple[str, str]:
    """
    从 summary 中提取 emoji 前缀和正文。

    AI 写入格式: "📅 偏好周末下午时段消费" → ("📅", "偏好周末下午时段消费")
    手动写入无 emoji: "喜欢打中式" → ("", "喜欢打中式")
    """
    if not summary:
        return "", ""
    # 检查第一个字符是否为 emoji（非 ASCII 且非中文常用范围）
    first_char = summary[0]
    if ord(first_char) > 0x2600 and summary[1:2] == " ":
        return first_char, summary[2:].strip()
    return "", summary.strip()


def _format_time(dt: datetime | None) -> str | None:
    """格式化时间为 ISO 字符串。"""
    if dt is None:
        return None
    return dt.isoformat() if hasattr(dt, "isoformat") else str(dt)


# ---------------------------------------------------------------------------
# RNS1.1：get_task_list_v2（TASK-1 扩展版任务列表）
# ---------------------------------------------------------------------------


@trace_service("获取扩展版任务列表", "Get task list v2")
async def get_task_list_v2(
    user_id: int,
    site_id: int,
    status: str,
    page: int,
    page_size: int,
) -> dict:
    """
    扩展版任务列表（TASK-1）。

    返回 { items, total, page, pageSize, performance }。

    逻辑：
    1. _get_assistant_id() 获取 assistant_id
    2. 查询 coach_tasks 带分页（LIMIT/OFFSET + COUNT(*)）
    3. fdw_queries 批量获取会员信息、余额、lastVisitDays
    4. fdw_queries.get_salary_calc() 获取绩效概览
    5. 查询 ai_cache 获取 aiSuggestion
    6. 组装 TaskListResponse

    扩展字段（lastVisitDays/balance/aiSuggestion）采用优雅降级。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)

        # ── 0. 预加载 RS 范围参数 + 需排除的 relationship_building member_id ──
        # CHANGE 2026-03-25 | 分页准确性修复：在 SQL 层面排除 RS 范围外的保底任务，
        # 而非在内存中过滤（内存过滤会导致 total 跨页不准确）。
        # 先查 ETL 获取该助教所有关系对的 RS 值，筛出不满足范围的 member_id，
        # 然后在 SQL COUNT + 分页查询中排除这些 (task_type, member_id) 组合。
        from app.services.task_generator import load_params as _load_tg_params
        try:
            tg_params = _load_tg_params(conn, site_id)
        except Exception:
            logger.warning("加载任务生成器参数失败，使用默认值", exc_info=True)
            tg_params = {"rs_min_for_relationship": 1.0, "rs_max_for_relationship": 6.0}
        rb_rs_min = Decimal(str(tg_params.get("rs_min_for_relationship", 1.0)))
        rb_rs_max = Decimal(str(tg_params.get("rs_max_for_relationship", 6.0)))

        # 查询该助教所有 RS 值，筛出不满足展示范围的 member_id
        rb_exclude_member_ids: list[int] = []
        try:
            from app.services.fdw_queries import _fdw_context
            with _fdw_context(conn, site_id) as cur:
                cur.execute(
                    """
                    SELECT member_id, COALESCE(rs_display, 0) AS rs
                    FROM app.v_dws_member_assistant_relation_index
                    WHERE assistant_id = %s
                    """,
                    (assistant_id,),
                )
                for row in cur.fetchall():
                    rs_val = Decimal(str(row[1]))
                    if not (rb_rs_min < rs_val < rb_rs_max):
                        rb_exclude_member_ids.append(row[0])
        except Exception:
            logger.warning("ETL 查询 RS 排除列表失败，降级为不排除", exc_info=True)

        # ── 1. 查询任务列表（带分页 + 总数） ──
        # 状态映射：前端 pending → active
        db_status = "active" if status == "pending" else status

        # 构建排除条件：relationship_building + member_id 不在 RS 范围内
        # 当排除列表为空时不加额外条件
        exclude_clause = ""
        runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
        query_params_count: list = [site_id, assistant_id, db_status, *runtime_params]
        query_params_page: list = [site_id, assistant_id, db_status, *runtime_params]
        if rb_exclude_member_ids:
            exclude_clause = (
                " AND NOT (task_type = 'relationship_building' AND member_id = ANY(%s))"
            )
            query_params_count.append(rb_exclude_member_ids)
            query_params_page.append(rb_exclude_member_ids)

        with conn.cursor() as cur:
            # 总数（已排除 RS 范围外的保底任务）
            cur.execute(
                f"""
                SELECT COUNT(*)
                FROM biz.coach_tasks
                WHERE site_id = %s AND assistant_id = %s AND status = %s
                {runtime_clause}
                {exclude_clause}
                """,
                query_params_count,
            )
            total = cur.fetchone()[0]

            # 分页查询（同样排除）
            offset = (page - 1) * page_size
            query_params_page.extend([page_size, offset])
            cur.execute(
                f"""
                SELECT id, task_type, status, priority_score, is_pinned,
                       expires_at, created_at, member_id, abandon_reason
                FROM biz.coach_tasks
                WHERE site_id = %s AND assistant_id = %s AND status = %s
                {runtime_clause}
                {exclude_clause}
                ORDER BY is_pinned DESC,
                         priority_score DESC NULLS LAST,
                         created_at ASC
                LIMIT %s OFFSET %s
                """,
                query_params_page,
            )
            tasks = cur.fetchall()
        conn.commit()

        if not tasks:
            # 即使无任务也需要返回绩效概览
            performance = build_performance_summary(conn, site_id, assistant_id)
            return {
                "items": [],
                "total": 0,
                "page": page,
                "page_size": page_size,
                "performance": performance,
            }

        member_ids = list({t[7] for t in tasks})

        # ── 2-5+8. 单连接批量查询所有 ETL 数据 ──
        # CHANGE 2026-03-23 | 性能优化：合并 7 次独立 ETL 连接为 1 次
        member_info_map: dict[int, dict] = {}
        balance_map: dict[int, Decimal] = {}
        last_visit_map: dict[int, int | None] = {}
        rs_map: dict[int, Decimal] = {}
        recent60d_map: dict[int, dict] = {}
        batch_data: dict | None = None
        try:
            from app.services.runtime_context import as_runtime_today_param
            _ref_date = as_runtime_today_param(site_id, conn=conn)
            batch_data = fdw_queries.batch_query_for_task_list(
                conn, site_id, assistant_id, member_ids,
                _ref_date.year, _ref_date.month,
            )
            member_info_map = batch_data["member_info"]
            balance_map = batch_data["balance"]
            last_visit_map = batch_data["last_visit"]
            rs_map = batch_data["rs"]
            wbi_map = batch_data.get("wbi", {})
            recent60d_map = batch_data.get("recent60d", {})
        except Exception:
            logger.warning("ETL 批量查询失败，降级为空数据", exc_info=True)

        # ── 6. 查询 ai_cache 获取 aiSuggestion（优雅降级） ──
        ai_suggestion_map: dict[int, str] = {}
        try:
            runtime_ctx = get_runtime_context(site_id, conn=conn)
            if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id:
                member_id_strs = [f"{runtime_ctx.sandbox_instance_id}:{mid}" for mid in member_ids]
            else:
                member_id_strs = [str(mid) for mid in member_ids]
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT target_id, result_json
                    FROM biz.ai_cache
                    WHERE site_id = %s
                      AND target_id = ANY(%s)
                      AND cache_type = 'app4_analysis'
                    ORDER BY created_at DESC
                    """,
                    (site_id, member_id_strs),
                )
                seen: set[str] = set()
                for row in cur.fetchall():
                    target_id_str = str(row[0])
                    if target_id_str not in seen:
                        seen.add(target_id_str)
                        result = row[1] if isinstance(row[1], dict) else {}
                        summary = result.get("summary", "")
                        if summary:
                            raw_target = target_id_str.split(":", 1)[-1]
                            ai_suggestion_map[int(raw_target)] = summary
            conn.commit()
        except Exception:
            logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)

        # ── 7. 查询备注存在性（has_note） ──
        task_ids = [t[0] for t in tasks]
        has_note_set: set[int] = set()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT DISTINCT task_id
                    FROM biz.notes
                    WHERE task_id = ANY(%s)
                    """,
                    (task_ids,),
                )
                for row in cur.fetchall():
                    has_note_set.add(row[0])
            conn.commit()
        except Exception:
            logger.warning("查询备注存在性失败", exc_info=True)

        # ── 8. 绩效概览（使用批量查询的预取数据） ──
        # CHANGE 2026-03-23 | 复用 batch_data 避免额外 3 次 ETL 连接
        performance = build_performance_summary(
            conn, site_id, assistant_id, batch_data=batch_data,
        )

        # ── 9. 组装 items ──
        items = []
        for task_row in tasks:
            (task_id, task_type, task_status, priority_score,
             is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row

            info = member_info_map.get(member_id, {})
            customer_name = info.get("nickname") or info.get("member_name") or "未知客户"
            rs_score = rs_map.get(member_id, Decimal("0"))

            balance = balance_map.get(member_id)
            wbi = wbi_map.get(member_id, {})
            last_visit_days_val = last_visit_map.get(member_id)
            ideal_interval = wbi.get("ideal_interval_days")
            recent60d = recent60d_map.get(member_id, {})
            # CHANGE 2026-03-24 | 预期天数：ideal_interval_days - last_visit_days
            # 正数=距预期到店还有余量，负数=已逾期
            expected_days: int | None = None
            if ideal_interval is not None and last_visit_days_val is not None:
                expected_days = round(ideal_interval - last_visit_days_val)

            items.append({
                "id": task_id,
                "customer_name": customer_name,
                "customer_avatar": "/assets/images/avatar-default.png",
                "task_type": task_type,
                "task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
                "deadline": _format_time(expires_at),
                "heart_score": float(rs_score),
                "hobbies": [],  # 暂无数据源，返回空数组
                "is_pinned": bool(is_pinned),
                "has_note": task_id in has_note_set,
                "status": task_status,
                "last_visit_days": last_visit_days_val,
                "balance": float(balance) if balance is not None else None,
                "ai_suggestion": ai_suggestion_map.get(member_id),
                "expected_days": expected_days,
                "ideal_interval_days": round(ideal_interval) if ideal_interval is not None else None,
                # CHANGE 2026-03-27 | 近60天服务汇总（口径同 task-detail serviceSummary）
                # 无记录时返回 0.0 而非 None，确保前端始终能显示数值
                "recent60d_hours": recent60d.get("hours", 0.0),
                "recent60d_income": recent60d.get("income", 0.0),
            })

        return {
            "items": items,
            "total": total,
            "page": page,
            "page_size": page_size,
            "performance": performance,
        }

    finally:
        conn.close()


def build_performance_summary(
    conn, site_id: int, assistant_id: int, *, batch_data: dict | None = None,
) -> dict:
    """
    构建绩效概览（PerformanceSummary）。

    CHANGE 2026-03-23: 支持 batch_data 参数复用预查询数据，避免额外 ETL 连接。
    当 batch_data 为 None 时（如无任务的空列表场景），回退到独立查询。
    课时/档位/客户数从 monthly_summary（每日更新）取实时数据，
    不再依赖月初结算的 salary_calc。收入仍从 salary_calc 取（如有）。

    CHANGE 2026-05-02 | now 改用 RuntimeContext.business_date，沙箱不读「未来」月份。
    """
    from app.services.runtime_context import as_runtime_today_param
    now = as_runtime_today_param(site_id, conn=conn)
    year, month = now.year, now.month

    if batch_data:
        # 复用批量查询的预取数据
        summary = batch_data.get("monthly_summary")
        salary = batch_data.get("salary_cur")
        prev_salary = batch_data.get("salary_prev")
        prev_month = month - 1 if month > 1 else 12
    else:
        # 回退：独立查询（无任务时的空列表场景）
        summary = None
        try:
            summary = fdw_queries.get_monthly_summary(conn, site_id, assistant_id, year, month)
        except Exception:
            logger.warning("FDW 查询当月 monthly_summary 失败", exc_info=True)

        salary = None
        try:
            salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
        except Exception:
            logger.warning("FDW 查询当月 salary_calc 失败", exc_info=True)

        prev_year, prev_month = (year, month - 1) if month > 1 else (year - 1, 12)
        prev_salary = None
        try:
            prev_salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, prev_year, prev_month)
        except Exception:
            logger.warning("FDW 查询上月绩效失败", exc_info=True)

    # 收入：优先 salary_calc，无则为 0（月中尚未结算属正常）
    current_income = salary["total_income"] if salary else 0.0
    prev_income = prev_salary["total_income"] if prev_salary else 0.0
    income_trend, income_trend_dir = compute_income_trend(current_income, prev_income)

    # CHANGE 2026-03-24 | 档位节点从 cfg_performance_tier 配置表构建，不再依赖 salary_calc
    # feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取，禁止硬编码
    # intent: 修复前端 tier_nodes=[0] 导致进度条无刻度的 bug
    tiers: list[dict] = []
    if batch_data and batch_data.get("performance_tiers"):
        tiers = batch_data["performance_tiers"]
    else:
        try:
            tiers = fdw_queries.get_performance_tiers(conn, site_id)
        except Exception:
            logger.warning("查询 cfg_performance_tier 失败", exc_info=True)

    # 构建 tier_nodes: 各档位的 min_hours（如 [0, 120, 150, 180, 210]）
    tier_nodes = [t["min_hours"] for t in tiers] if tiers else [0]

    # 课时/档位/客户数：从 monthly_summary 取实时值
    total_hours = summary["effective_hours"] if summary else 0.0
    basic_hours = summary["base_hours"] if summary else 0.0
    bonus_hours = summary["bonus_hours"] if summary else 0.0
    total_customers = summary["unique_customers"] if summary else 0
    coach_level = summary["coach_level"] if summary else (salary["coach_level"] if salary else "")

    # current_tier：根据 total_hours 在 tier_nodes 中的位置计算数组索引（0-based）
    # 不能用 tier_id（数据库主键），前端把 current_tier 当数组下标用
    current_tier = 0
    for i, node in enumerate(tier_nodes):
        if total_hours >= node:
            current_tier = i
        else:
            break

    # next_tier_hours / tier_completed: 根据 effective_hours 和 tier_nodes 计算
    tier_completed = False
    next_tier_hours = 0.0
    if tiers:
        # 找到当前所在档位的下一档 min_hours
        matched_next = None
        for t in tiers:
            if t["min_hours"] > total_hours:
                matched_next = t["min_hours"]
                break
        if matched_next is not None:
            next_tier_hours = matched_next
        else:
            # 已达到或超过最高档
            tier_completed = True
            next_tier_hours = tiers[-1]["min_hours"]

    # bonus_money: 达到下一档后因抽成降低能多拿的钱（基础课 + 打赏课）
    # CHANGE 2026-03-24 | 公式:
    #   基础课节省 = next_tier_min_hours × (当前档 base_deduction - 下一档 base_deduction)
    #   打赏课节省 = 当前打赏课时 × bonus_course_price × (当前档 bonus_ratio - 下一档 bonus_ratio)
    #   bonus_money = 基础课节省 + 打赏课节省
    # intent: 展示升档的实际收益激励（替代已过期的 sprint_bonus）
    # assumptions: base_deduction/bonus_deduction_ratio 从 cfg_performance_tier 读取；
    #   bonus_course_price 从 salary_calc.incentive_rate 读取（禁止硬编码 190）
    bonus_money = 0.0
    if not tier_completed and tiers and len(tiers) >= 2:
        # 找到当前所在档位和下一档
        current_tier_data = None
        next_tier_data = None
        for i, t in enumerate(tiers):
            if t["min_hours"] > total_hours:
                next_tier_data = t
                current_tier_data = tiers[i - 1] if i > 0 else tiers[0]
                break
        if current_tier_data and next_tier_data:
            # 基础课节省：用下一档的 min_hours（升档后整月课时都按新抽成算）
            base_ded_diff = current_tier_data.get("base_deduction", 0) - next_tier_data.get("base_deduction", 0)
            base_saving = next_tier_data["min_hours"] * base_ded_diff if base_ded_diff > 0 else 0.0

            # 打赏课节省：当前打赏课时 × 单价 × 抽成比例差
            bonus_ratio_diff = (
                current_tier_data.get("bonus_deduction_ratio", 0)
                - next_tier_data.get("bonus_deduction_ratio", 0)
            )
            bonus_course_price = salary.get("incentive_rate", 0.0) if salary else 0.0
            bonus_saving = bonus_hours * bonus_course_price * bonus_ratio_diff if bonus_ratio_diff > 0 else 0.0

            bonus_money = round(base_saving + bonus_saving, 2)

    return {
        "total_hours": total_hours,
        "total_income": current_income,
        "total_customers": total_customers,
        "month_label": f"{month}月",
        "tier_nodes": [float(n) for n in tier_nodes] if tier_nodes else [0],
        "basic_hours": basic_hours,
        "bonus_hours": bonus_hours,
        "current_tier": current_tier,
        "next_tier_hours": next_tier_hours,
        "tier_completed": tier_completed,
        "bonus_money": bonus_money,
        "income_trend": income_trend,
        "income_trend_dir": income_trend_dir,
        "prev_month": f"{prev_month}月",
        "current_tier_label": coach_level,
    }


# ---------------------------------------------------------------------------
# 按 member_id 查询最高优先级 active 任务
# ---------------------------------------------------------------------------

# 任务类型优先级排序（数值越小越优先）
_TASK_TYPE_SORT_ORDER: dict[str, int] = {
    "high_priority_recall": 0,
    "priority_recall": 1,
    "follow_up_visit": 2,
    "relationship_building": 3,
}


@trace_service("按会员查询任务详情", "Get task detail by member")
async def get_task_by_member(
    member_id: int,
    user_id: int,
    site_id: int,
) -> dict:
    """
    按 member_id 查询当前助教的最高优先级 active 任务，返回完整详情。

    逻辑：
    1. 查询 coach_tasks WHERE assistant_id + member_id + site_id + status='active'
    2. 多条时按 _TASK_TYPE_SORT_ORDER 取优先级最高的一条
    3. 复用 get_task_detail() 返回完整详情

    权限校验：无 active 任务 → 404。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)

        runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT id, task_type
                FROM biz.coach_tasks
                WHERE site_id = %s AND assistant_id = %s AND member_id = %s
                  AND status = 'active'
                  {runtime_clause}
                """,
                [site_id, assistant_id, member_id, *runtime_params],
            )
            rows = cur.fetchall()

        if not rows:
            raise HTTPException(status_code=404, detail="该会员无活跃任务")

        # 按优先级排序，取最高的一条
        best = min(rows, key=lambda r: _TASK_TYPE_SORT_ORDER.get(r[1], 99))
        task_id = best[0]
    finally:
        conn.close()

    # 复用完整详情逻辑
    return await get_task_detail(task_id, user_id, site_id)


# ---------------------------------------------------------------------------
# RNS1.1：get_task_detail（TASK-2 任务详情完整版）
# ---------------------------------------------------------------------------


@trace_service("获取任务详情", "Get task detail")
async def get_task_detail(
    task_id: int,
    user_id: int,
    site_id: int,
) -> dict:
    """
    任务详情完整版（TASK-2）。

    返回基础信息 + retentionClues + talkingPoints + serviceSummary
    + serviceRecords + aiAnalysis + notes + customerId。

    权限校验：任务不存在 → 404，不属于当前助教 → 403。
    """
    conn = _get_connection()
    try:
        assistant_id = _get_assistant_id(conn, user_id, site_id)

        # ── 1. 查询任务基础信息 ──
        runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT id, task_type, status, priority_score, is_pinned,
                       expires_at, created_at, member_id, abandon_reason,
                       assistant_id, site_id
                FROM biz.coach_tasks
                WHERE id = %s
                {runtime_clause}
                """,
                [task_id, *runtime_params],
            )
            row = cur.fetchone()

        if not row:
            raise HTTPException(status_code=404, detail="任务不存在")

        task_assistant_id = row[9]
        task_site_id = row[10]
        if task_site_id != site_id or task_assistant_id != assistant_id:
            raise HTTPException(status_code=403, detail="无权访问该任务")

        member_id = row[7]
        task_type = row[1]
        task_status = row[2]
        is_pinned = row[4]
        expires_at = row[5]

        # ── 2. FDW 查询会员信息 ──
        member_info_map: dict[int, dict] = {}
        try:
            member_info_map = fdw_queries.get_member_info(conn, site_id, [member_id])
        except Exception:
            logger.warning("FDW 查询会员信息失败", exc_info=True)

        info = member_info_map.get(member_id, {})
        customer_name = info.get("nickname") or "未知客户"
        customer_phone = info.get("mobile") or ""

        # 余额（用于前端储值等级展示）
        balance = Decimal("0")
        try:
            balance_map = fdw_queries.get_member_balance(conn, site_id, [member_id])
            balance = balance_map.get(member_id, Decimal("0"))
        except Exception:
            logger.warning("FDW 查询会员余额失败", exc_info=True)

        # RS 指数
        # CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app（直连 ETL 库）
        rs_score = Decimal("0")
        try:
            from app.services.fdw_queries import _fdw_context

            with _fdw_context(conn, site_id) as cur:
                cur.execute(
                    """
                    SELECT COALESCE(rs_display, 0)
                    FROM app.v_dws_member_assistant_relation_index
                    WHERE assistant_id = %s AND member_id = %s
                    """,
                    (assistant_id, member_id),
                )
                rs_row = cur.fetchone()
                if rs_row:
                    rs_score = Decimal(str(rs_row[0]))
        except Exception:
            logger.warning("ETL 查询 RS 指数失败", exc_info=True)

        # ── 3. 查询维客线索 ──
        retention_clues = []
        try:
            runtime_ctx = get_runtime_context(site_id, conn=conn)
            member_target_id = (
                f"{runtime_ctx.sandbox_instance_id}:{member_id}"
                if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id
                else str(member_id)
            )
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT id, category, summary, detail, source
                    FROM public.member_retention_clue
                    WHERE member_id = %s AND site_id = %s
                      AND is_hidden = false
                    ORDER BY recorded_at DESC
                    """,
                    (member_id, site_id),
                )
                for clue_row in cur.fetchall():
                    category = clue_row[1] or ""
                    summary_raw = clue_row[2] or ""
                    detail = clue_row[3]
                    source = clue_row[4] or "manual"

                    emoji, text = _extract_emoji_and_text(summary_raw)
                    tag = sanitize_tag(category)
                    tag_color = _CATEGORY_COLOR_MAP.get(tag, "primary")

                    retention_clues.append({
                        "tag": tag,
                        "tag_color": tag_color,
                        "emoji": emoji,
                        "text": text,
                        "source": source,
                        "desc": detail,
                    })
            conn.commit()
        except Exception:
            logger.warning("查询维客线索失败", exc_info=True)

        # ── 4. 查询 AI 缓存（talkingPoints + aiAnalysis） ──
        talking_points: list[str] = []
        ai_analysis = {"summary": "", "suggestions": []}
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT cache_type, result_json
                    FROM biz.ai_cache
                    WHERE target_id = %s AND site_id = %s
                      AND cache_type IN ('app4_analysis', 'app5_talking_points')
                    ORDER BY created_at DESC
                    """,
                    (member_target_id, site_id),
                )
                seen_types: set[str] = set()
                for cache_row in cur.fetchall():
                    cache_type = cache_row[0]
                    if cache_type in seen_types:
                        continue
                    seen_types.add(cache_type)

                    result = cache_row[1] if isinstance(cache_row[1], dict) else {}

                    if cache_type == "app5_talking_points":
                        # talkingPoints: 话术列表
                        points = result.get("talking_points", [])
                        if isinstance(points, list):
                            talking_points = [str(p) for p in points]
                    elif cache_type == "app4_analysis":
                        # aiAnalysis: summary + suggestions
                        ai_analysis = {
                            "summary": result.get("summary", ""),
                            "suggestions": result.get("suggestions", []),
                        }
            conn.commit()
        except Exception:
            logger.warning("查询 AI 缓存失败", exc_info=True)

        # ── 5. FDW 查询服务记录（最多 20 条） ──
        service_records_raw: list[dict] = []
        try:
            service_records_raw = fdw_queries.get_service_records_for_task(
                conn, site_id, assistant_id, member_id, limit=20
            )
        except Exception:
            logger.warning("FDW 查询服务记录失败", exc_info=True)

        # CHANGE 2026-03-25 | 统计范围：近60天；列表不限
        # 预估规则：当月且日期 ≤ 5号
        # CHANGE 2026-05-02 | today 改用 business_date，沙箱不读「未来」60 天
        from datetime import timedelta
        from app.services.runtime_context import as_runtime_today_param
        today = as_runtime_today_param(site_id, conn=conn)
        cutoff_60d = today - timedelta(days=60)
        is_estimate_month = today.day <= 5

        service_records = []
        total_hours_60d = 0.0
        total_income_60d = 0.0
        count_60d = 0
        for rec in service_records_raw:
            hours = rec.get("service_hours", 0.0)
            income = rec.get("income", 0.0)

            # 判断是否在60天窗口内（用于统计）
            settle_time = rec.get("settle_time")
            in_60d = False
            if settle_time:
                rec_date = settle_time.date() if hasattr(settle_time, "date") else None
                if rec_date and rec_date >= cutoff_60d:
                    in_60d = True
                    total_hours_60d += hours
                    total_income_60d += income
                    count_60d += 1

            # 时间格式化
            date_str = ""
            if settle_time:
                if hasattr(settle_time, "strftime"):
                    date_str = settle_time.strftime("%Y-%m-%d")
                else:
                    date_str = str(settle_time)[:10]

            raw_course_type = rec.get("course_type", "")
            type_class = map_course_type_class(raw_course_type)

            # CHANGE 2026-03-25 | 预估规则：当月且日期 ≤ 5号
            rec_is_estimate = False
            if settle_time and is_estimate_month:
                rec_date_val = settle_time.date() if hasattr(settle_time, "date") else None
                if rec_date_val and rec_date_val.year == today.year and rec_date_val.month == today.month:
                    rec_is_estimate = True

            service_records.append({
                "table": rec.get("table_name"),
                "type": raw_course_type or "基础课",
                "type_class": type_class,
                "record_type": "recharge" if type_class == "recharge" else "course",
                "duration": hours,
                "duration_raw": rec.get("service_hours_raw"),
                "income": income,
                "is_estimate": rec_is_estimate,
                "drinks": rec.get("drinks"),
                "date": date_str,
            })

        avg_income = total_income_60d / count_60d if count_60d else 0.0
        service_summary = {
            "total_hours": round(total_hours_60d, 2),
            "total_income": round(total_income_60d, 2),
            "avg_income": round(avg_income, 2),
        }

        # ── 6. 查询备注（最多 20 条） ──
        notes: list[dict] = []
        has_note = False
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT id, content, type, ai_score, created_at, score,
                           rating_service_willingness, rating_revisit_likelihood
                    FROM biz.notes
                    WHERE task_id = %s
                    ORDER BY created_at DESC
                    LIMIT 20
                    """,
                    (task_id,),
                )
                for note_row in cur.fetchall():
                    has_note = True
                    note_type = note_row[2] or "normal"
                    # type → tag_type/tag_label 映射
                    tag_label = "回访" if note_type == "follow_up" else "普通"
                    # CHANGE 2026-03-27 | 备注联调：补充 score（用户星星评分）和 ai_score
                    # ai_score 是 AI 应用 6 评分（1-10），score 是用户手动星星评分（1-5）
                    user_score = note_row[5]
                    ai_score_val = note_row[3]

                    notes.append({
                        "id": note_row[0],
                        "content": note_row[1] or "",
                        "tag_type": note_type,
                        "tag_label": tag_label,
                        "created_at": _format_time(note_row[4]) or "",
                        "score": user_score,
                        "ai_score": ai_score_val,
                    })
            conn.commit()
        except Exception:
            logger.warning("查询备注失败", exc_info=True)

        # ── 7. 组装 TaskDetailResponse ──
        return {
            "id": task_id,
            "customer_name": customer_name,
            "customer_phone": customer_phone,
            "customer_avatar": "/assets/images/avatar-default.png",
            "task_type": task_type,
            "task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
            "deadline": _format_time(expires_at),
            "heart_score": float(rs_score),
            "hobbies": [],
            "is_pinned": bool(is_pinned),
            "has_note": has_note,
            "status": task_status,
            "customer_id": member_id,
            "balance": float(balance),
            "retention_clues": retention_clues,
            "talking_points": talking_points,
            "service_summary": service_summary,
            "service_records": service_records,
            "ai_analysis": ai_analysis,
            "notes": notes,
        }

    finally:
        conn.close()
