# -*- coding: utf-8 -*-
"""
比对 ODS 数据库实际列 vs docs/api-reference/summary/*.md 文档中的响应字段。
改进版：
1. 只提取"响应字段详解"章节的字段（排除请求参数）
2. 同时用 camelCase 原名和 snake_case 转换名做双向匹配
3. 对 ODS 连写小写列名（如 siteid）也尝试匹配 camelCase（如 siteId）

用法: python scripts/compare_ods_vs_summary_v2.py
"""
import os, re, sys, json
from pathlib import Path
from dotenv import load_dotenv
import psycopg2

from neozqyy_shared.repo_root import ensure_repo_root
ensure_repo_root()

load_dotenv()

SUMMARY_DIR = Path("docs/api-reference/summary")
ODS_SCHEMA = "ods"
META_COLS = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"}

# CHANGE P20260214-170000: 从全局黑名单移除 start_time/end_time/starttime/endtime
# intent: 这些字段在部分 API 中是请求参数，但在 assistant_accounts_master、
#         group_buy_packages、member_stored_value_cards 中是真正的响应业务字段。
#         全局过滤会导致误报"ODS有/MD无"。
# assumptions: 请求参数的 startTime/endTime 不会出现在"响应字段详解"章节中
#              （extract_response_fields 已限定只提取该章节），因此无需在此处过滤。
# 请求参数（不应出现在 ODS 列比对中）
# 注意：start_time/end_time 不在此列表中——它们在多张表中是响应业务字段，
#       而作为请求参数时已被 extract_response_fields 的章节限定逻辑排除。
REQUEST_PARAMS = {
    "page", "limit",
    "rangestarttime", "rangeendtime", "range_start_time", "range_end_time",
    "startpaytime", "endpaytime", "start_pay_time", "end_pay_time",
    "siteid_param", "settletype_param", "paymentmethod_param",
    "isfirst_param", "goodssalestype", "goods_sales_type",
    "issalesbind", "is_sales_bind", "existsgoodsstock", "exists_goods_stock",
    "goodssecondcategoryid_param", "goodsstate_param",
    "querytype", "query_type", "issalemanuser", "is_sale_man_user",
    "couponusestatus", "coupon_use_status",
    "total",  # 分页 total 不是业务字段
}

# CHANGE P20260214-210000: 添加包装器/容器字段忽略列表
# intent: 某些 API 响应中的顶层字段是数组/对象容器（如 goodsCategoryList），
#         ODS 穿透存储其子元素而非容器本身，MD 文档中记录了容器字段但 ODS 无对应列
# assumptions: 这些字段在 ODS 中不建列，其子元素已被展开存储
WRAPPER_FIELDS = {
    "goodscategorylist",  # stock_goods_category_tree: 分类树的上级数组节点
}

DSN = os.getenv("PG_DSN") or os.getenv("DATABASE_URL")
if not DSN:
    print("ERROR: 需要设置 PG_DSN 或 DATABASE_URL 环境变量", file=sys.stderr)
    sys.exit(1)


def get_ods_columns(conn):
    cur = conn.cursor()
    cur.execute("""
        SELECT table_name, column_name
        FROM information_schema.columns
        WHERE table_schema = %s
        ORDER BY table_name, ordinal_position
    """, (ODS_SCHEMA,))
    result = {}
    for table_name, col_name in cur.fetchall():
        result.setdefault(table_name, set()).add(col_name)
    cur.close()
    return result


def camel_to_snake(name):
    """camelCase / PascalCase → snake_case"""
    s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()


def extract_response_fields(md_path: Path) -> set:
    """
    只提取"四、响应字段详解"章节中的字段名。
    排除请求参数和 siteProfile 子字段。
    """
    text = md_path.read_text(encoding="utf-8")
    fields = set()

    # 找到"响应字段详解"章节的起始位置
    response_start = None
    for pattern in [
        r'##\s*四、响应字段详解',
        r'##\s*四、.*响应字段',
        r'##\s*响应字段详解',
        r'###\s*4\.',
    ]:
        m = re.search(pattern, text)
        if m:
            response_start = m.start()
            break

    if response_start is None:
        # 回退：提取所有表格字段
        response_text = text
    else:
        # 找到下一个同级章节（## 五、或 ## 五 或文件结尾）
        next_section = re.search(r'\n##\s*(五|六|七|八|九|十|5|6|7|8|9)', text[response_start + 10:])
        if next_section:
            response_text = text[response_start:response_start + 10 + next_section.start()]
        else:
            response_text = text[response_start:]

    # 从响应字段章节提取表格中的字段名
    # 匹配 | `fieldName` | 或 | fieldName | 格式
    table_pattern = re.compile(
        r'^\|\s*`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s*\|',
        re.MULTILINE
    )

    # CHANGE P20260214-200000: 用分隔行检测替代 skip_words 硬编码
    # intent: skip_words 方式会误杀与表头词同名的业务字段（如 remark、type、note），
    #         改为利用 Markdown 表格固定结构（表头行 → 分隔行 → 数据行）来跳过表头
    # assumptions: 所有 summary MD 文档的表格均遵循标准 Markdown 格式，
    #              分隔行匹配 |---...| 模式，分隔行的前一行即为表头行
    separator_pattern = re.compile(r'^\|[\s\-:|]+\|', re.MULTILINE)

    lines = response_text.split('\n')
    # 标记哪些行是表头行（分隔行的前一行）
    header_lines = set()
    for i, line in enumerate(lines):
        if separator_pattern.match(line) and i > 0:
            header_lines.add(i - 1)

    # 跟踪是否在 siteProfile/tableProfile 子字段展开区域中
    # CHANGE P20260214-210000: 修复 siteProfile 子节跳过逻辑
    # intent: 之前的逻辑会跳过整个 siteProfile 子节（包括 siteProfile 字段本身），
    #         但 siteProfile 作为 object/jsonb 字段应该被提取，只需跳过其展开的子字段
    # assumptions: siteProfile/tableProfile 子节标题后紧跟的表格中，第一行是 siteProfile 字段本身
    #              （应保留），后续行是展开的子字段（应跳过）。
    #              如果子节只有一行（siteProfile 本身），则不跳过任何内容。
    in_site_profile = False
    site_profile_field_seen = False
    for i, line in enumerate(lines):
        # 检测 siteProfile/tableProfile 子节标题
        if re.search(r'siteProfile|门店信息快照|tableProfile|台桌信息快照', line, re.IGNORECASE):
            if '###' in line or '####' in line:
                in_site_profile = True
                site_profile_field_seen = False
                continue

        # 检测离开 siteProfile 子节（遇到下一个同级或更高级标题）
        if in_site_profile and re.match(r'\s*#{2,4}\s+', line):
            if not re.search(r'siteProfile|tableProfile|门店信息快照|台桌信息快照', line, re.IGNORECASE):
                in_site_profile = False
                site_profile_field_seen = False

        # 在 siteProfile 子节中：保留 siteProfile/tableProfile 字段本身，跳过展开的子字段
        if in_site_profile:
            m_check = table_pattern.match(line)
            if m_check:
                field_name = m_check.group(1).strip().lower()
                if field_name in ('siteprofile', 'tableprofile') and not site_profile_field_seen:
                    # 这是 siteProfile/tableProfile 字段本身，保留（不跳过）
                    site_profile_field_seen = True
                    # 不 continue，让下面的提取逻辑处理
                else:
                    # 这是展开的子字段，跳过
                    continue
            elif i not in header_lines and not separator_pattern.match(line):
                # 非表格行（空行、标题等），不跳过
                pass

        # 跳过表头行（分隔行的前一行）和分隔行本身
        if i in header_lines or separator_pattern.match(line):
            continue

        m = table_pattern.match(line)
        if m:
            field = m.group(1).strip()
            if not field.startswith('---'):
                fields.add(field)

    return fields


def match_fields(md_fields: set, ods_cols: set):
    """
    智能匹配 MD 字段和 ODS 列。
    返回 (matched, md_only, ods_only)
    """
    matched = set()
    md_remaining = set()
    ods_remaining = set(ods_cols)

    # 构建 ODS 列的查找索引
    ods_lower = {c.lower(): c for c in ods_cols}
    # 也构建去下划线版本 → 原名映射
    ods_no_underscore = {}
    for c in ods_cols:
        key = c.lower().replace("_", "")
        ods_no_underscore.setdefault(key, c)

    for field in md_fields:
        field_lower = field.lower()
        field_snake = camel_to_snake(field).lower()
        field_no_sep = field_lower.replace("_", "")

        found = False

        # 1. 精确匹配（小写）
        if field_lower in ods_lower:
            matched.add((field, ods_lower[field_lower]))
            ods_remaining.discard(ods_lower[field_lower])
            found = True
        # 2. snake_case 匹配
        elif field_snake in ods_lower:
            matched.add((field, ods_lower[field_snake]))
            ods_remaining.discard(ods_lower[field_snake])
            found = True
        # 3. 去下划线匹配（处理 camelCase vs 连写小写）
        elif field_no_sep in ods_no_underscore:
            matched.add((field, ods_no_underscore[field_no_sep]))
            ods_remaining.discard(ods_no_underscore[field_no_sep])
            found = True

        if not found:
            md_remaining.add(field)

    return matched, md_remaining, ods_remaining


def is_request_param(field: str) -> bool:
    """判断字段是否为请求参数"""
    f = field.lower().replace("_", "")
    return f in {p.replace("_", "") for p in REQUEST_PARAMS}


def main():
    conn = psycopg2.connect(DSN)
    ods_tables = get_ods_columns(conn)
    conn.close()

    md_files = sorted(SUMMARY_DIR.glob("*.md"))
    report = []

    for md_path in md_files:
        table_name = md_path.stem
        md_fields_raw = extract_response_fields(md_path)

        # 过滤请求参数和包装器字段
        md_fields = {f for f in md_fields_raw
                     if not is_request_param(f)
                     and f.lower() not in WRAPPER_FIELDS}

        if table_name not in ods_tables:
            report.append({
                "table": table_name,
                "status": "NO_ODS_TABLE",
                "md_fields_count": len(md_fields),
                "note": "summary 文档存在但 ODS 中无对应表"
            })
            continue

        ods_cols = ods_tables[table_name] - META_COLS
        matched, md_only, ods_only = match_fields(md_fields, ods_cols)

        if md_only or ods_only:
            report.append({
                "table": table_name,
                "status": "DIFF",
                "ods_count": len(ods_cols),
                "md_count": len(md_fields),
                "matched": len(matched),
                "md_only": sorted(md_only),
                "ods_only": sorted(ods_only),
            })
        else:
            report.append({
                "table": table_name,
                "status": "MATCH",
                "ods_count": len(ods_cols),
                "md_count": len(md_fields),
                "matched": len(matched),
            })

    # 检查 ODS 中有但 summary 中没有的表
    md_table_names = {p.stem for p in md_files}
    for t in sorted(ods_tables.keys()):
        if t not in md_table_names:
            report.append({
                "table": t,
                "status": "NO_MD_FILE",
                "ods_count": len(ods_tables[t] - META_COLS),
                "note": "ODS 表存在但无对应 summary 文档"
            })

    # 输出
    print(f"\n{'='*70}")
    print(f"ODS vs Summary 字段比对报告 (v2 — 仅响应字段，智能匹配)")
    print(f"ODS 表数: {len(ods_tables)} | Summary 文档数: {len(md_files)}")
    print(f"{'='*70}\n")

    match_count = sum(1 for r in report if r["status"] == "MATCH")
    diff_count = sum(1 for r in report if r["status"] == "DIFF")
    no_ods = sum(1 for r in report if r["status"] == "NO_ODS_TABLE")

    print(f"完全匹配: {match_count} | 有差异: {diff_count} | 无ODS表: {no_ods}\n")

    for entry in report:
        if entry["status"] == "MATCH":
            print(f"  ✅ {entry['table']} — 完全匹配 (匹配:{entry['matched']} ODS:{entry['ods_count']} MD:{entry['md_count']})")
        elif entry["status"] == "DIFF":
            print(f"\n  ❌ {entry['table']} — 有差异 (匹配:{entry['matched']} ODS:{entry['ods_count']} MD:{entry['md_count']})")
            if entry["md_only"]:
                print(f"     📄 MD有/ODS无 ({len(entry['md_only'])}): {', '.join(entry['md_only'])}")
            if entry["ods_only"]:
                print(f"     🗄️  ODS有/MD无 ({len(entry['ods_only'])}): {', '.join(entry['ods_only'])}")
        elif entry["status"] == "NO_ODS_TABLE":
            print(f"\n  ⚠️  {entry['table']} — {entry['note']} (MD字段数: {entry['md_fields_count']})")
        elif entry["status"] == "NO_MD_FILE":
            print(f"\n  ⚠️  {entry['table']} — {entry['note']} (ODS字段数: {entry['ods_count']})")

    # JSON 输出
    json_path = Path(os.environ.get("ETL_REPORT_ROOT", "reports")) / "ods_vs_summary_comparison_v2.json"
    json_path.parent.mkdir(parents=True, exist_ok=True)
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"\n📁 JSON 报告: {json_path}")


if __name__ == "__main__":
    main()

# AI_CHANGELOG:
# - 日期: 2026-02-14
#   Prompt: P20260214-150000 — ODS 数据库结构 vs summary MD 文档字段比对
#   直接原因: 用户要求通过查询 ods schema 与 25 个 summary MD 文档进行字段比对
#   变更摘要: 新建 v2 比对脚本，改进点：(1) 仅提取"响应字段详解"章节排除请求参数
#             (2) 三重匹配（精确/camelCase→snake_case/去下划线）(3) 跳过 siteProfile 子字段
#   风险与验证: 纯分析脚本，无运行时影响；验证：python scripts/compare_ods_vs_summary_v2.py
#
# - 日期: 2026-02-14
#   Prompt: P20260214-170000 — assistant_accounts_master 的 start_time/end_time 误报修复
#   直接原因: REQUEST_PARAMS 全局黑名单包含 start_time/end_time，但这些字段在 3 张表中是响应业务字段，
#             且仅对 MD 侧过滤未对 ODS 侧过滤，导致假差异
#   变更摘要: 从 REQUEST_PARAMS 移除 start_time/end_time/starttime/endtime 4 个值，
#             添加 CHANGE 标记注释说明原因
#   风险与验证: 验证：python scripts/compare_ods_vs_summary_v2.py，确认 assistant_accounts_master、
#             member_stored_value_cards 变为完全匹配，group_buy_packages 不再误报 start_time/end_time
#
# - 日期: 2026-02-14
#   Prompt: P20260214-190000 — goods_stock_movements 的 remark 字段误报修复
#   直接原因: skip_words 集合包含 'remark'（本意过滤表头词），但 remark 在 goods_stock_movements、
#             member_balance_changes、store_goods_master 中是真实业务字段名，导致被误过滤为表头词
#   变更摘要: 从 skip_words 移除 'remark' 和 'note'，添加 CHANGE 标记注释
#   风险与验证: 验证：python scripts/compare_ods_vs_summary_v2.py，完全匹配从 12→14，
#             goods_stock_movements(19/19)、member_balance_changes(28/28) 变为完全匹配
#
# - 日期: 2026-02-14
#   Prompt: P20260214-200000 — group_buy_packages 的 type 字段误报修复
#   直接原因: skip_words 硬编码方式无法区分表头词和同名业务字段（type/remark/note 等），
#             根本原因是过滤策略错误——应该用 Markdown 表格结构（分隔行检测）来跳过表头行
#   变更摘要: 用分隔行检测（separator_pattern + header_lines）替代 skip_words 硬编码，
#             彻底消除"表头词 vs 业务字段同名"的误过滤问题
#   风险与验证: 验证：python scripts/compare_ods_vs_summary_v2.py，
#             group_buy_packages 的 type 正确匹配（匹配 39，ODS有/MD无 不再包含 type）
#
# - 日期: 2026-02-14
#   Prompt: P20260214-210000 — siteProfile 误跳过 + goodsCategoryList 包装器字段忽略
#   直接原因: (1) siteProfile 子节跳过逻辑会跳过 siteProfile 字段本身，但它在 table_fee_transactions、
#             platform_coupon_redemption_records 等表中是 object/jsonb 字段应被提取
#             (2) goodsCategoryList 是 stock_goods_category_tree 的上级数组容器节点，ODS 穿透存储子元素
#   变更摘要: (1) 重写 siteProfile 子节跳过逻辑，保留 siteProfile/tableProfile 字段本身，只跳过展开的子字段
#             (2) 新增 WRAPPER_FIELDS 忽略列表，过滤 goodsCategoryList
#   风险与验证: 验证：python scripts/compare_ods_vs_summary_v2.py，完全匹配从 14→17
