#!/usr/bin/env python3
"""询盘评分引擎 v3.0

基于 v2.0 升级，新增 chat_signals 维度（聊天记录评分）：
  1. v2 已有：13 维评分 + Top X% 滚动窗口 + missing 追踪 + 二次升级
  2. v3 新增：chat_signals 综合维度（上限 +30 分），含 5 个子项：
     - chat_turn_count：买家有效消息条数
     - chat_specificity：MOQ/shipping/spec/lead-time 等具体询问
     - chat_intent：目的国/数量/付款方式等成交意图信号
     - chat_negative：免费样品/超低价/价格能再低吗等负面信号（扣分）
     - chat_engagement_quality：礼貌词/技术追问/上下文连续性

输入 normalized JSON 新增字段 chat_messages: List[{speaker, timestamp, content}]
输出 schema 兼容 v1.0/v2.0 钉钉推送和 AI 表写入。
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple


# ============================================================
# 默认配置（installer 装机时覆盖）
# ============================================================

DEFAULT_CONFIG: Dict[str, Any] = {
    # 评分维度权重上限（v2: 旧 8 维 + 新 5 维，满分上调到 130，可被 cfg 覆盖）
    "weights": {
        "country": 15,        # 国家分层
        "l_level": 12,        # 买家 L 层级（旧字段，可由 panel.buyer_level 覆盖）
        "registration": 8,    # 账号注册时长
        "company": 12,        # 公司/官网/邮箱
        "product": 15,        # 产品匹配度
        "quantity": 18,       # 采购数量
        "demand": 10,         # 需求明确度
        "customs": 10,        # 海关/历史采购
        # ===== v2 新增 5 类权重 =====
        "business_type": 10,         # 商业类型（Distributor/Wholesaler/Trading/Manufacturer）
        "behavior_engagement": 10,   # 90 天行为活跃度（浏览/登录天数/有效询盘）
        "industry_match": 12,        # 最常采购行业 vs 本店主营品比对
        "buyer_features": 5,         # buyer_features 标签匹配卖点
        "risk_backend": 0,           # 后台风险标签（命中即无效，不计正分）
        # ===== v3 新增：聊天信号综合维度 =====
        "chat_signals": 30,          # 聊天记录综合评分（5 子项叠加，上限 30）
    },
    # 分级阈值（可被滚动 Top X% 覆盖）
    "thresholds": {
        "S": 80,    # ≥80 = S，前 5%
        "A": 65,    # ≥65 = A，前 20%
        "B": 45,    # ≥45 = B
        "C": 25,    # ≥25 = C，< 25 = 无效
    },
    # MOQ 默认值（商家不填则用此）
    "default_moq": 1000,
    # Top X% 滚动窗口设置
    "rolling_window": {
        "enabled": False,           # 默认关闭，装机后自动开启
        "days": 30,                 # 取最近 30 天询盘做基线
        "top_s_percent": 5,         # 前 5% 为 S
        "top_a_percent": 20,        # 前 20% 为 A（含 S）
        "min_samples": 50,          # 样本不足 50 条时不启用，回退到固定阈值
    },
    # 强信号定义（二次升级用）
    "strong_signal_quantity": 10000,
}

# 关键风险 → 直接判无效
CRITICAL_RISKS = {"phishing_link", "advertising", "account_invalid", "competitor_quote"}

# v2 新增：后台原生标签命中以下任何一个 → 直接判无效
BACKEND_INVALID_LABELS = {
    "背调来源信息无效",
    "info_invalid",
    "blacklisted",
    "黑名单",
    "spam",
    "垃圾询盘",
}

# 软风险扣分
SOFT_RISK_DEDUCTIONS = {
    "unrealistic_delivery": -8,
    "low_margin": -8,
    "non_core_low_budget": -8,
    "suspicious_attachment": -12,
}

# 人工确认关键词正则
MANUAL_CONFIRMATION_PATTERNS = [
    (re.compile(r"\b(price|quote|quotation|报价|价格)\b", re.I), "价格/报价需人工确认"),
    (re.compile(r"\b(discount|折扣)\b", re.I), "折扣需人工确认"),
    (re.compile(r"\bsample\s*fee\b|样品费", re.I), "样品费需人工确认"),
    (re.compile(r"\b(freight|shipping|运费)\b", re.I), "运费需人工确认"),
    (re.compile(r"\b(delivery|lead\s*time|交期)\b", re.I), "交期/物流承诺需人工确认"),
    (re.compile(r"\brohs\b", re.I), "RoHS 认证需人工核验"),
    (re.compile(r"\bce\b", re.I), "CE 认证需人工核验"),
    (re.compile(r"\bfda\b", re.I), "FDA 认证需人工核验"),
    (re.compile(r"\b(contract|合同)\b", re.I), "合同条款需人工确认"),
    (re.compile(r"\b(payment|payable|付款)\b", re.I), "付款条件需人工确认"),
    (re.compile(r"\b(deposit|订金|定金)\b", re.I), "定金条款需人工确认"),
]


# ============================================================
# 基础工具
# ============================================================

def is_missing(value: Any) -> bool:
    return value is None or value == "" or value == [] or value == {}


def add_missing(missing: List[str], field: str, value: Any) -> bool:
    if is_missing(value):
        missing.append(field)
        return True
    return False


def merge_config(user_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    """深合并用户 config 到默认 config。"""
    import copy
    merged = copy.deepcopy(DEFAULT_CONFIG)
    if not user_config:
        return merged
    for key, val in user_config.items():
        if isinstance(val, dict) and isinstance(merged.get(key), dict):
            merged[key].update(val)
        else:
            merged[key] = val
    return merged


# ============================================================
# 各维度评分（点数从 config.weights 取）
# ============================================================

def score_country(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    if add_missing(missing, "buyer_country", inq.get("buyer_country")):
        return 0, "国家缺失，国家分 0"
    tier = inq.get("country_tier")
    max_pt = w["country"]
    if tier == "target":
        return max_pt, f"目标国家 +{max_pt}"
    if tier == "acceptable":
        pt = round(max_pt * 0.55)
        return pt, f"可做国家 +{pt}"
    if tier == "low_priority":
        return 2, "暂不重点国家 +2"
    if tier == "high_risk":
        return 0, "高风险国家 +0"
    missing.append("country_tier")
    return 0, "国家分层缺失，国家分 0"


def score_l_level(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    level = inq.get("buyer_l_level")
    if add_missing(missing, "buyer_l_level", level):
        return 0, "买家 L 层级缺失，L 分 0"
    max_pt = w["l_level"]
    mapping = {
        "L4": max_pt,
        "L3": round(max_pt * 0.83),
        "L2": round(max_pt * 0.50),
        "L1": round(max_pt * 0.25),
    }
    pt = mapping.get(str(level).upper(), 0)
    return pt, f"买家层级 {level} +{pt}"


def score_registration(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    months = inq.get("account_registered_months")
    if add_missing(missing, "account_registered_months", months):
        return 0, "账号注册时间缺失，注册分 0"
    max_pt = w["registration"]
    if months >= 12:
        return max_pt, f"账号注册 12 个月以上 +{max_pt}"
    if months >= 3:
        pt = round(max_pt * 0.625)
        return pt, f"账号注册 3-11 个月 +{pt}"
    return 2, "新注册账号 +2，需要补问"


def score_company(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    company = inq.get("company") or {}
    name = company.get("name")
    website = company.get("website")
    email_ok = company.get("email_domain_match")
    max_pt = w["company"]
    if name and website and email_ok is True:
        return max_pt, f"公司名+官网+邮箱域名匹配 +{max_pt}"
    if name or website or email_ok is True or company.get("phone"):
        pt = round(max_pt * 0.50)
        return pt, f"公司信息部分可见 +{pt}"
    missing.append("company.name_or_website")
    return 0, "公司/官网信息缺失，公司分 0"


def score_product(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    if add_missing(missing, "product", inq.get("product")):
        return 0, "产品缺失，产品分 0"
    match = inq.get("product_match")
    max_pt = w["product"]
    if match == "core":
        return max_pt, f"主营产品 +{max_pt}"
    if match == "acceptable":
        pt = round(max_pt * 0.6)
        return pt, f"可做产品 +{pt}"
    if match == "non_core":
        return 2, "非主营产品 +2"
    missing.append("product_match")
    return 0, "产品匹配未知，产品分 0"


def score_quantity(inq: Dict, missing: List[str], w: Dict, default_moq: int) -> Tuple[int, str]:
    qty = inq.get("quantity")
    if add_missing(missing, "quantity", qty):
        return 0, "采购数量缺失，数量分 0"
    moq = inq.get("moq") or default_moq
    max_pt = w["quantity"]
    if qty >= 10000:
        return max_pt, f"采购数量 ≥1万 +{max_pt}"
    if qty >= moq:
        pt = round(max_pt * 0.67)
        return pt, f"采购数量达到 MOQ +{pt}"
    if qty >= moq * 0.5:
        pt = round(max_pt * 0.39)
        return pt, f"采购数量达到 50% MOQ +{pt}"
    return 2, "采购数量低于 50% MOQ +2"


def score_demand(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    demand = inq.get("demand") or {}
    fields = ["spec", "use_case", "target_market", "certification", "timeline"]
    present = [f for f in fields if not is_missing(demand.get(f))]
    max_pt = w["demand"]
    if not present:
        missing.append("demand")
        return 0, "需求字段缺失，明确度 0"
    if len(present) >= 4:
        return max_pt, f"需求信息较完整（{len(present)}/5） +{max_pt}"
    if len(present) >= 2:
        pt = round(max_pt * 0.5)
        return pt, f"需求信息部分明确（{len(present)}/5） +{pt}"
    return 1, "只有 1 项需求信息 +1"


def score_customs(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    customs = inq.get("customs_data") or {}
    status = customs.get("status")
    max_pt = w["customs"]
    if status == "match":
        return max_pt, f"海关/历史采购匹配 +{max_pt}"
    if status == "weak":
        pt = round(max_pt * 0.5)
        return pt, f"海关/历史采购弱线索 +{pt}"
    if status == "none":
        return 0, "无海关/历史采购数据 +0"
    missing.append("customs_data.status")
    return 0, "海关字段缺失 +0"


def risk_deduction(inq: Dict) -> Tuple[int, List[str], bool]:
    risks = inq.get("risk_signals") or []
    # v2 新增：先看后台原生标签
    backend_labels = inq.get("risk_labels_from_backend") or []
    backend_hit = []
    for lbl in backend_labels:
        for invalid in BACKEND_INVALID_LABELS:
            if invalid in str(lbl):
                backend_hit.append(str(lbl))
                break
    if backend_hit:
        return -25, [f"后台风险标签命中：{', '.join(backend_hit)}，直接无效"], True
    # v2 新增：blacklisted_count > 0 → 直接无效
    behavior = inq.get("behavior_90d") or {}
    bl = behavior.get("blacklisted_count")
    if isinstance(bl, (int, float)) and bl > 0:
        return -25, [f"90 天内被加入黑名单 {bl} 次，直接无效"], True
    if any(r in CRITICAL_RISKS for r in risks):
        hit = [r for r in risks if r in CRITICAL_RISKS]
        return -20, [f"关键风险：{', '.join(hit)}，进入无效/隔离"], True
    deduction = 0
    reasons = []
    for r in risks:
        if r in SOFT_RISK_DEDUCTIONS:
            deduction += SOFT_RISK_DEDUCTIONS[r]
            reasons.append(f"风险扣分 {r} {SOFT_RISK_DEDUCTIONS[r]}")
    # v2 新增：spam_inquiry_count > 0 软扣分
    spam = behavior.get("spam_inquiry_count")
    if isinstance(spam, (int, float)) and spam > 0:
        deduction += -10
        reasons.append(f"90 天垃圾询盘 {spam} 次 -10")
    deduction = max(-25, deduction)
    if not reasons:
        reasons.append("无明显风险扣分 0")
    return deduction, reasons, False


# ============================================================
# v2 新增 5 类评分维度
# ============================================================

def score_business_type(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    """商业类型评分。Distributor/Wholesaler/Trading/Manufacturer/Individual。"""
    bt = inq.get("business_type")
    if add_missing(missing, "business_type", bt):
        return 0, "商业类型缺失 +0"
    max_pt = w.get("business_type", 10)
    bt_text = str(bt).lower()
    if any(k in bt_text for k in ["distributor", "wholesaler", "经销商", "批发"]):
        return max_pt, f"商业类型 {bt} +{max_pt}（高价值渠道）"
    if any(k in bt_text for k in ["trading", "贸易"]):
        pt = round(max_pt * 0.8)
        return pt, f"商业类型 {bt} +{pt}"
    if any(k in bt_text for k in ["manufacturer", "factory", "制造", "工厂"]):
        pt = round(max_pt * 0.5)
        return pt, f"商业类型 {bt} +{pt}（同行/工厂）"
    if any(k in bt_text for k in ["individual", "personal", "个人"]):
        return -5, f"商业类型 {bt} -5（个人买家）"
    return 2, f"商业类型 {bt} +2"


def score_behavior_engagement(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    """90 天行为活跃度综合分（浏览数 + 登录天数 + 有效询盘数）。"""
    behavior = inq.get("behavior_90d") or {}
    pv = behavior.get("product_view_count")
    login = behavior.get("login_days")
    vi = behavior.get("valid_inquiry_count")
    if pv is None and login is None and vi is None:
        missing.append("behavior_90d")
        return 0, "90 天行为数据缺失 +0"
    max_pt = w.get("behavior_engagement", 10)
    pts = 0
    parts = []
    if isinstance(pv, (int, float)):
        if pv >= 500: pts += 4; parts.append(f"浏览{pv}≥500 +4")
        elif pv >= 100: pts += 2; parts.append(f"浏览{pv} +2")
        else: parts.append(f"浏览{pv} +0")
    if isinstance(login, (int, float)):
        if login >= 30: pts += 4; parts.append(f"登录{login}天≥30 +4")
        elif login >= 10: pts += 2; parts.append(f"登录{login}天 +2")
        else: parts.append(f"登录{login}天 +0")
    if isinstance(vi, (int, float)):
        if vi >= 3: pts += 4; parts.append(f"有效询盘{vi}≥3 +4")
        elif vi >= 1: pts += 2; parts.append(f"有效询盘{vi} +2")
        else: pts += -2; parts.append(f"有效询盘{vi} -2（活跃但无询盘）")
    pts = max(0, min(max_pt, pts))
    return pts, "活跃度: " + "; ".join(parts) + f" => +{pts}"


def score_industry_match(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    """most_inquired_industries 与本店主营品比对。"""
    behavior = inq.get("behavior_90d") or {}
    match = behavior.get("industry_match")
    if not match:
        missing.append("behavior_90d.most_inquired_industries")
        return 0, "买家最常采购行业缺失 +0"
    max_pt = w.get("industry_match", 12)
    if match == "match":
        return max_pt, f"最常采购行业完全匹配本店主营 +{max_pt}"
    if match == "partial":
        pt = round(max_pt * 0.5)
        return pt, f"最常采购行业部分匹配 +{pt}"
    if match == "mismatch":
        return -8, "最常采购行业与本店主营完全错配 -8"
    return 0, "行业匹配未知 +0"


def score_buyer_features(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    """买家特征标签匹配本店卖点。"""
    features = inq.get("buyer_features") or []
    if not features:
        return 0, "买家特征缺失 +0"
    max_pt = w.get("buyer_features", 5)
    pos = {"高质量偏好": 2, "产品目录偏好": 2, "图片找品偏好": 1, "线上支付订单偏好": 1, "运输方式偏好": 1}
    neg = {"商品价格敏感": -3, "价格敏感": -3}
    pts = 0
    hits = []
    for f in features:
        for k, v in pos.items():
            if k in f:
                pts += v
                hits.append(f"{k} +{v}")
                break
        for k, v in neg.items():
            if k in f:
                pts += v
                hits.append(f"{k} {v}")
                break
    pts = max(-5, min(max_pt, pts))
    if not hits:
        return 0, f"买家特征 {features} 未命中卖点 +0"
    return pts, "买家特征: " + "; ".join(hits) + f" => {pts:+d}"


# ============================================================
# v3 新增：聊天信号评分
# ============================================================

# 聊天关键词库（基于真实询盘场景手工归纳，可后续扩充到 config）
CHAT_KEYWORDS = {
    # 具体性信号（5 分/类，最多覆盖 4 类 = 20）
    "specificity": {
        "moq": [r"\bmoq\b", r"minimum\s+order", r"最小起订", r"起订量"],
        "shipping": [r"\bship", r"\bdelivery", r"freight", r"shipping cost", r"运费", r"发货"],
        "spec": [r"\bspec", r"specification", r"\bram\b", r"\bssd\b", r"\bhdmi\b",
                 r"\bcpu\b", r"\bgpu\b", r"\binch\b", r"\bport\b", r"配置", r"规格", r"参数"],
        "lead_time": [r"lead\s*time", r"how\s+long", r"when\s+can", r"\beta\b",
                      r"交期", r"多久", r"什么时候"],
        "pricing": [r"\bprice\b", r"quotation", r"\bquote\b", r"unit\s+price", r"报价", r"价格"],
        "customization": [r"customi[sz]", r"\blogo\b", r"\boem\b", r"\bodm\b",
                          r"private\s+label", r"定制", r"贴牌"],
    },
    # 成交意图信号（4 分/类，最多 3 类 = 12）
    "intent": {
        "destination": [r"\bship\s+to\b", r"to\s+(my\s+)?country", r"to\s+\w+\?$",
                        r"发到", r"运到", r"运往"],
        "quantity": [r"\bpieces\b", r"\bpcs\b", r"\bunits\b", r"\bcontainer\b",
                     r"\bpallet\b", r"\b\d+\s*(piece|unit|pcs|pc|set)", r"采购"],
        "payment": [r"\bpayment\b", r"\bpaypal\b", r"\bwire\b", r"\btt\b", r"\blc\b",
                    r"\bescrow\b", r"\bcredit\s+card\b", r"trade\s+assurance",
                    r"付款", r"支付"],
        "sample_order": [r"sample\s+order", r"sample\s+first", r"try\s+(it|first)",
                         r"先来一些", r"先试样"],
    },
    # 负面信号（-3 分/类，最多 -12）
    "negative": {
        "free_sample_only": [r"free\s+sample", r"send\s+(me\s+)?free", r"免费样品"],
        "price_pressure": [r"can\s+you\s+(lower|reduce)\s+(the\s+)?price",
                           r"too\s+expensive", r"discount\s+(more|further)", r"cheaper",
                           r"价格太高", r"能不能便宜", r"再低点"],
        "competitor_quote": [r"other\s+supplier\s+offer", r"competitor\s+price",
                             r"better\s+price\s+(from|with)", r"别家"],
        "fishing": [r"send\s+(your\s+)?catalog\s+only", r"just\s+(checking|looking)",
                    r"只是看看", r"随便问问"],
    },
    # 礼貌/专业信号（2 分/类，最多 3 类 = 6）
    "engagement": {
        "polite": [r"\bthank", r"\bplease\b", r"\bappreciate\b", r"\bregards\b",
                   r"谢谢", r"感谢", r"麻烦"],
        "tech_followup": [r"\bcan\s+i\b", r"\bdoes\s+it\s+(have|support)",
                          r"\bcompatible\b", r"\bwarranty\b", r"\bcertification\b",
                          r"是否", r"支持", r"兼容"],
        "context_continuity": [r"\bas\s+(you|we)\s+(said|mentioned)",
                               r"\bregarding\s+(your|the)\s+(last|previous)",
                               r"上次", r"刚才"],
    },
}


def _hit_any(text: str, patterns: List[str]) -> bool:
    return any(re.search(p, text, re.I) for p in patterns)


def _count_hits(text: str, group: Dict[str, List[str]]) -> List[str]:
    """返回命中的子类名列表。"""
    hits = []
    for sub_name, patterns in group.items():
        if _hit_any(text, patterns):
            hits.append(sub_name)
    return hits


def score_chat_signals(inq: Dict, missing: List[str], w: Dict) -> Tuple[int, str]:
    """聊天信号综合评分。
    输入：inq.chat_messages = [{speaker, timestamp, content}, ...]
    回退：若无 chat_messages，使用 conversation_excerpt（兼容 v2 数据）
    """
    max_pt = w.get("chat_signals", 30)
    msgs = inq.get("chat_messages") or []
    excerpt = inq.get("conversation_excerpt") or ""

    if not msgs and not excerpt:
        missing.append("chat_messages")
        return 0, "无聊天数据 +0"

    # 拼接所有 buyer 消息文本
    if msgs:
        buyer_msgs = [m for m in msgs if str(m.get("speaker", "")).lower() in ("buyer", "客户", "买家")]
        buyer_text = " ".join(str(m.get("content", "")) for m in buyer_msgs)
        turn_count = len(buyer_msgs)
        source_label = f"chat_messages({len(msgs)}条)"
    else:
        # v2 conversation_excerpt 回退：粗略按 "buyer:" 切
        buyer_text = excerpt
        turn_count = excerpt.lower().count("buyer:") or 1
        source_label = "conversation_excerpt(回退)"

    parts = []
    pts = 0

    # 1) chat_turn_count：买家有效消息条数（最多 +6）
    if turn_count >= 3:
        pts += 6; parts.append(f"买家发言{turn_count}条≥3 +6")
    elif turn_count >= 2:
        pts += 3; parts.append(f"买家发言{turn_count}条 +3")
    elif turn_count >= 1:
        pts += 1; parts.append(f"买家发言{turn_count}条 +1")

    # 2) chat_specificity：MOQ/shipping/spec/lead-time/pricing/customization（每类 +3，最多 +12）
    spec_hits = _count_hits(buyer_text, CHAT_KEYWORDS["specificity"])
    spec_pts = min(12, len(spec_hits) * 3)
    if spec_pts:
        pts += spec_pts; parts.append(f"具体询问{spec_hits} +{spec_pts}")

    # 3) chat_intent：目的国/数量/付款/样品订单（每类 +3，最多 +9）
    intent_hits = _count_hits(buyer_text, CHAT_KEYWORDS["intent"])
    intent_pts = min(9, len(intent_hits) * 3)
    if intent_pts:
        pts += intent_pts; parts.append(f"成交意图{intent_hits} +{intent_pts}")

    # 4) chat_negative：负面信号（每类 -3，最多 -12）
    neg_hits = _count_hits(buyer_text, CHAT_KEYWORDS["negative"])
    neg_pts = -min(12, len(neg_hits) * 3)
    if neg_pts:
        pts += neg_pts; parts.append(f"负面信号{neg_hits} {neg_pts}")

    # 5) chat_engagement_quality：礼貌/技术追问/上下文（每类 +1，最多 +3）
    eng_hits = _count_hits(buyer_text, CHAT_KEYWORDS["engagement"])
    eng_pts = min(3, len(eng_hits))
    if eng_pts:
        pts += eng_pts; parts.append(f"沟通质量{eng_hits} +{eng_pts}")

    pts = max(-12, min(max_pt, pts))
    reason = f"聊天信号({source_label}): " + ("; ".join(parts) if parts else "无可识别信号") + f" => {pts:+d}"
    return pts, reason


# ============================================================
# 分级 + 二次升级 + Top X% 滚动窗口
# ============================================================

def has_strong_signal(inq: Dict, cfg: Dict) -> bool:
    qty = inq.get("quantity") or 0
    match = inq.get("product_match")
    company = inq.get("company") or {}
    demand = inq.get("demand") or {}
    count = 0
    if qty >= cfg["strong_signal_quantity"] and match in {"core", "acceptable"}:
        count += 1
    if inq.get("country_tier") == "target" and demand.get("spec"):
        count += 1
    if company.get("website") and (company.get("email_domain_match") is True or company.get("name")):
        count += 1
    if demand.get("timeline") and any(
        w in str(demand.get("timeline")).lower() for w in ["bulk", "order", "sample", "month", "week", "asap"]
    ):
        count += 1
    if (inq.get("customs_data") or {}).get("status") == "match":
        count += 1
    return count >= 2


def grade_from_score(score: int, critical_invalid: bool, inq: Dict, cfg: Dict, dynamic_thresholds: Optional[Dict[str, int]] = None) -> str:
    """分级。
    - critical_invalid → 无效
    - dynamic_thresholds（Top X% 滚动窗口算出）优先于 cfg.thresholds
    - is_re_evaluation 且强信号 → 65 分起可越级升 S
    """
    th = dynamic_thresholds or cfg["thresholds"]
    if critical_invalid or score < th["C"]:
        return "无效"
    strong = has_strong_signal(inq, cfg)
    if score >= th["S"]:
        return "S"
    # 二次升级：B/C 类原询盘补问后回复强信号，65+ 直升 S
    if inq.get("status_context", {}).get("is_re_evaluation") and strong and score >= th["A"]:
        return "S"
    if score >= th["A"]:
        return "A"
    if score >= th["B"]:
        return "B"
    return "C"


def compute_rolling_thresholds(history_scores: List[int], cfg: Dict) -> Optional[Dict[str, int]]:
    """从历史分数分布算 Top X% 阈值。
    样本不足 min_samples 返回 None（回退固定阈值）。
    """
    rw = cfg.get("rolling_window", {})
    if not rw.get("enabled"):
        return None
    if len(history_scores) < rw.get("min_samples", 50):
        return None
    sorted_scores = sorted(history_scores, reverse=True)
    n = len(sorted_scores)
    s_idx = max(0, int(n * rw["top_s_percent"] / 100) - 1)
    a_idx = max(0, int(n * rw["top_a_percent"] / 100) - 1)
    s_thr = sorted_scores[s_idx]
    a_thr = sorted_scores[a_idx]
    return {
        "S": s_thr,
        "A": a_thr,
        "B": cfg["thresholds"]["B"],
        "C": cfg["thresholds"]["C"],
    }


# ============================================================
# 输出辅助
# ============================================================

def next_action(grade: str) -> str:
    return {
        "S": "立即推送老板 + 业务员 15 分钟内主跟进",
        "A": "推送业务员 1 小时内跟进；补齐缺失字段",
        "B": "生成 3-5 轮补问草稿；待业务员确认后发送或进入授权接待专项",
        "C": "低频培育；如客户授权可进入接待专项试探",
        "无效": "隔离归档；不分配业务员",
    }[grade]


def human_confirmation_items(inq: Dict, grade: str) -> List[str]:
    demand = inq.get("demand") or {}
    demand_vals = [str(v) for v in demand.values() if not is_missing(v)]
    text = " ".join(
        str(v).lower()
        for v in [inq.get("conversation_excerpt"), *demand_vals, " ".join(inq.get("risk_signals") or [])]
        if v is not None
    )
    items = []
    if not is_missing(demand.get("certification")):
        items.append("认证需人工核验证书")
    for pattern, item in MANUAL_CONFIRMATION_PATTERNS:
        if pattern.search(text) and item not in items:
            items.append(item)
    if grade == "S":
        items.append("S 类进入报价/样品/会议推进前必须人工确认")
    if inq.get("risk_signals"):
        items.append("风险信号需人工复核")
    if not items:
        items.append("无高风险承诺；客户询价仍需人工确认价格交期")
    return items


def followup_questions(grade: str, missing: Iterable[str]) -> List[str]:
    if grade == "无效":
        return []
    qs = []
    ms = set(missing)
    if "quantity" in ms or "demand" in ms:
        qs.append("请问您主要用于什么场景？预计采购数量大概多少？")
    if "demand" in ms or "product_match" in ms:
        qs.append("是否有尺寸、包装、LOGO 或其他定制要求？")
    qs.append("产品最终销售或使用在哪个国家？是否需要相关认证？")
    if "company.name_or_website" in ms:
        qs.append("方便提供公司网站或公司名称吗？")
    qs.append("您现在是比价、拿样，还是准备批量采购？预计什么时候推进？")
    return qs[:5]


# ============================================================
# 主入口
# ============================================================

def score_one(inq: Dict, cfg: Dict, dynamic_thresholds: Optional[Dict] = None) -> Dict:
    missing: List[str] = []
    w = cfg["weights"]
    default_moq = int(cfg.get("default_moq") or 1000)

    components = []
    for name, fn in [
        ("country", score_country),
        ("l_level", score_l_level),
        ("registration", score_registration),
        ("company", score_company),
        ("product", score_product),
    ]:
        pt, reason = fn(inq, missing, w)
        components.append({"name": name, "points": pt, "reason": reason})

    pt, reason = score_quantity(inq, missing, w, default_moq)
    components.append({"name": "quantity", "points": pt, "reason": reason})

    for name, fn in [("demand", score_demand), ("customs", score_customs)]:
        pt, reason = fn(inq, missing, w)
        components.append({"name": name, "points": pt, "reason": reason})

    # ===== v2 新增 4 类正分维度 =====
    for name, fn in [
        ("business_type", score_business_type),
        ("behavior_engagement", score_behavior_engagement),
        ("industry_match", score_industry_match),
        ("buyer_features", score_buyer_features),
    ]:
        pt, reason = fn(inq, missing, w)
        components.append({"name": name, "points": pt, "reason": reason})

    # ===== v3 新增：聊天信号综合维度 =====
    pt, reason = score_chat_signals(inq, missing, w)
    components.append({"name": "chat_signals", "points": pt, "reason": reason})

    risk_pt, risk_reasons, critical_invalid = risk_deduction(inq)
    components.append({"name": "risk", "points": risk_pt, "reason": "; ".join(risk_reasons)})

    raw = sum(c["points"] for c in components)
    # v3: 满分上限提到 160（v2 130 + chat_signals 30），最终归一为 100
    score_raw = max(0, min(160, raw))
    # 归一化到百分制，便于沿用旧阈值
    max_total = sum(w.get(k, 0) for k in [
        "country", "l_level", "registration", "company", "product",
        "quantity", "demand", "customs",
        "business_type", "behavior_engagement", "industry_match", "buyer_features",
        "chat_signals",
    ]) or 100
    score = round(score_raw * 100 / max_total)
    score = max(0, min(100, score))
    grade = grade_from_score(score, critical_invalid, inq, cfg, dynamic_thresholds)

    return {
        "inquiry_id": inq.get("inquiry_id"),
        "received_at": inq.get("received_at"),
        "updated_at": inq.get("updated_at"),
        "source": inq.get("source"),
        "assignee": inq.get("assignee"),
        "buyer_name": inq.get("buyer_name"),
        "buyer_country": inq.get("buyer_country"),
        "product": inq.get("product"),
        "score": score,
        "score_raw": score_raw,
        "score_max_total": max_total,
        "grade": grade,
        "expected_grade": inq.get("expected_grade"),
        "missing_fields": sorted(set(missing)),
        "reasons": [c["reason"] for c in components],
        "next_action": next_action(grade),
        "human_confirmation_items": human_confirmation_items(inq, grade),
        "followup_questions": followup_questions(grade, missing),
        "is_re_evaluation": bool(inq.get("status_context", {}).get("is_re_evaluation")),
        "thresholds_used": dynamic_thresholds or cfg["thresholds"],
        "raw_source_meta": inq.get("raw_source_meta"),
    }


def load_payload(path: Path) -> Tuple[Dict, List[Dict]]:
    data = json.loads(path.read_text(encoding="utf-8"))
    if isinstance(data, list):
        return {}, data
    if isinstance(data, dict) and "inquiries" in data:
        return data.get("config", data.get("customer_rules", {})), data["inquiries"]
    raise ValueError("输入必须是 list 或含 inquiries 字段的 dict")


def render_text(results: List[Dict]) -> str:
    counts: Dict[str, int] = {}
    for r in results:
        counts[r["grade"]] = counts.get(r["grade"], 0) + 1
    lines = ["询盘评分汇总", "=" * 40,
             "分布：" + ", ".join(f"{g}={counts.get(g,0)}" for g in ["S","A","B","C","无效"]), ""]
    for r in results:
        exp = f" 预期={r.get('expected_grade')}" if r.get("expected_grade") else ""
        lines.append(f"{r['inquiry_id']} | {r['score']}分 | {r['grade']}{exp}")
        lines.append(f"  产品: {r.get('product')} | 国家: {r.get('buyer_country')}")
        if r["missing_fields"]:
            lines.append("  缺字段: " + ", ".join(r["missing_fields"]))
        lines.append(f"  下一步: {r['next_action']}")
        lines.append("")
    return "\n".join(lines)


def build_summary(results: List[Dict]) -> Dict:
    grade_counts: Dict[str, int] = {}
    missing_counts: Dict[str, int] = {}
    source_counts: Dict[str, int] = {}
    assignee_counts: Dict[str, int] = {}
    for result in results:
        grade = result.get("grade") or "unknown"
        grade_counts[grade] = grade_counts.get(grade, 0) + 1
        source = result.get("source") or "unknown"
        source_counts[source] = source_counts.get(source, 0) + 1
        assignee = result.get("assignee") or "unassigned"
        assignee_counts[assignee] = assignee_counts.get(assignee, 0) + 1
        for field in result.get("missing_fields") or []:
            missing_counts[field] = missing_counts.get(field, 0) + 1
    high_priority = [
        {
            "inquiry_id": result.get("inquiry_id"),
            "grade": result.get("grade"),
            "score": result.get("score"),
            "assignee": result.get("assignee"),
            "next_action": result.get("next_action"),
        }
        for result in results
        if result.get("grade") in {"S", "A"}
    ]
    return {
        "total": len(results),
        "grade_counts": {grade: grade_counts.get(grade, 0) for grade in ["S", "A", "B", "C", "无效"]},
        "source_counts": source_counts,
        "assignee_counts": assignee_counts,
        "missing_fields_summary": dict(sorted(missing_counts.items(), key=lambda item: (-item[1], item[0]))),
        "high_priority_count": len(high_priority),
        "high_priority_items": high_priority,
    }


def main() -> None:
    p = argparse.ArgumentParser(description="询盘评分引擎 v2.0")
    p.add_argument("input", nargs="?", default="sample_inquiries.json")
    p.add_argument("--config", help="config.json（覆盖 DEFAULT_CONFIG）")
    p.add_argument("--history-scores", help="历史分数 JSON 数组文件，用于算 Top X 百分比阈值")
    p.add_argument("--json", action="store_true", help="输出 JSON")
    args = p.parse_args()

    user_cfg, inquiries = load_payload(Path(args.input))
    if args.config:
        user_cfg = merge_config(json.loads(Path(args.config).read_text(encoding="utf-8")))
    else:
        user_cfg = merge_config(user_cfg)

    dynamic_thr = None
    if args.history_scores:
        hist = json.loads(Path(args.history_scores).read_text(encoding="utf-8"))
        dynamic_thr = compute_rolling_thresholds(hist, user_cfg)

    results = [score_one(inq, user_cfg, dynamic_thr) for inq in inquiries]
    if args.json:
        print(json.dumps({
            "summary": build_summary(results),
            "results": results,
            "config_used": user_cfg["thresholds"],
            "dynamic_thresholds": dynamic_thr,
        }, ensure_ascii=False, indent=2))
    else:
        print(render_text(results))


if __name__ == "__main__":
    main()
