# -*- coding: utf-8 -*-
"""把《全国中考真题》正式库（data/approved/cn_exams_*，31 省 2229 卷 / 56594 题，零答案）
拍平成「组卷台」归一化 schema，作为**一个新教材「全国中考真题」并入同一个组卷台**。

由 gen_组卷台.py 调用（与 zujuan_exam / zujuan_v2 同款两处 hook）：
  data_recs, course_sub, fig_dict = zujuan_cn.load(HERE)
  data += data_recs
  course['全国中考真题'] = course_sub
  FIG.update(fig_dict)

## 轴怎么摆（教材 → 年级 → 节 这三级下拉是既有 UI，不改）
  tb 教材 = 全国中考真题
  gg 年级 = **省份**（按卷数倒序：四川 274、山东 249、江苏 228…）
            —— 上海那批用年份当轴，因为它只有一个城市；全国有 31 省 × 18 年，
               按年份分组每年 120+ 张卷子挤在一个下拉里没法用，按省分才选得动。
  cs 节号 = 省内序号（年份倒序，同年按城市名）
  cd 节名 = 「2019·苏州」 —— 省内唯一（同省同年同市只会有一场中考）
  s/src   = 「2019年」 —— 「来源」下拉做横切（选 江苏 + 2019年）
  第4位   = 「2019年」 —— 前端渲成 <optgroup>，省内按年分组
  t 题型  = 选择题/填空题/解答题（原卷大题头给的，可信；装饰题型名归一）
  d 难度  = 0（这批没有难度标注，前端会把难度筛选隐掉）
  f/mf    = 空；**配图走题干内联**（理由同上海批，见下）
  im      = 内联图 key 列表（前缀 `cn/`，与上海批的 `ex/` 不撞）

## ⚠ 为什么配图必须内联
32238 道题的公式本身就是一张图，且长在句子中间。挪到题干下方会得到
「求 的值」这种读不懂的题面。走 `![](asset://…)` 内联，前端 renderStem 就地换 <img>。

## 零答案红线
只取 `stem_markdown`。`answer_markdown` 早已另存（answers_withheld.jsonl 留在 drafts，
**不进正式库**），`solution_markdown` 一个字都不碰。load() 结束会自查一次。

## 去重
正式库的场次清单本身已按 (省, 年, 卷种, 市) 唯一化过，**不再做整卷判重**
（上海批那套 0.85 指纹判据是为「同一场录了两遍」设计的，这里没有这种情况；
 硬套只会误删 —— 见 [[feedback_checker_lags_behind]]）。
只留第一层：题面哈希精确去重，同一道题在选题器里露一次就够。

⚠ 与已在库的《上海中考真题》**不交叉**：本批 31 省不含上海（已核，上海卷数 0）。
"""
import json
import os
import re
from collections import OrderedDict, defaultdict

from PIL import Image

# 公共件全部复用上海批那套，别复制第二份（改一处就得改两处，迟早漂）
from zujuan_exam import (ANS_HEAD, ANS_MARK, IMG_RE, _fig_b64, _inject_tables,
                         _rescue_wmf, _strip_lead_no, _unicode_scripts)

TB = '全国中考真题'

# 装饰性题型名（孝感/娄底一系的「精心选一选，相信自己的判断！」）归一到标准三类。
# ⚠ 只归一**题型**这个筛选字段，题干里的原文一字不动。
_TYPE_NORM = [
    (re.compile(r'选一选|选择'), '选择题'),
    (re.compile(r'填一填|填空'), '填空题'),
    (re.compile(r'解一解|做一做|想一想|试一试|解答|综合|应用'), '解答题'),
]


def _tnorm(t):
    t = (t or '').strip()
    if not t:
        return '其他'
    for rx, name in _TYPE_NORM:
        if rx.search(t):
            return name
    return t


def _find_batch(HERE):
    """→ 正式库目录（data/approved/cn_exams_*，取最新的一份）。找不到返回 None。"""
    root = os.path.normpath(os.path.join(HERE, '..', '..', '数学学习系统', 'data', 'approved'))
    if not os.path.isdir(root):
        return None
    cands = sorted(d for d in os.listdir(root) if d.startswith('cn_exams_'))
    for d in reversed(cands):
        p = os.path.join(root, d)
        if os.path.exists(os.path.join(p, 'questions.jsonl')):
            return p
    return None


def load(HERE, verbose=True):
    """返回 (data_records, course_sublist_by_province, fig_base64_dict)。"""
    B = _find_batch(HERE)
    if not B:
        if verbose:
            print('  [全国中考] 未找到 data/approved/cn_exams_*/questions.jsonl，跳过')
        return [], OrderedDict(), {}

    papers = {p['external_id']: p for p in
              map(json.loads, open(os.path.join(B, 'exam_papers.jsonl'), encoding='utf-8'))
              if p.get('record_status') == 'approved'}
    qs = [q for q in map(json.loads, open(os.path.join(B, 'questions.jsonl'), encoding='utf-8'))
          if q.get('paper_external_id') in papers]

    # 小问不单独成卡（本批 parent_external_id 全空，留着是防以后批次带子题）
    n_sub = sum(1 for q in qs if q.get('parent_external_id'))
    qs = [q for q in qs if not q.get('parent_external_id')]

    # 题干被解析区污染的不进选题器（判据与上海批同源，只认两种铁证）
    polluted = [q['external_id'] for q in qs
                if ANS_MARK.search(q.get('stem_markdown') or '')
                or ANS_HEAD.match(q.get('stem_markdown') or '')]
    _pol = set(polluted)
    qs = [q for q in qs if q['external_id'] not in _pol]

    n_by_paper = defaultdict(int)
    for q in qs:
        n_by_paper[q['paper_external_id']] += 1

    # ── 卷名：省内「年份·城市」，同省同年同市只会有一场中考，撞名只可能是数据问题 ──
    label, by_prov = {}, defaultdict(list)
    for pid, p in papers.items():
        by_prov[p.get('city') or '其他'].append(pid)
    for prov, pids in by_prov.items():
        seen = defaultdict(int)
        for pid in sorted(pids, key=lambda x: (-(papers[x].get('year') or 0),
                                               papers[x].get('district') or '', x)):
            p = papers[pid]
            base = '%d·%s' % (p.get('year') or 0, p.get('district') or '省统一')
            seen[base] += 1
            label[pid] = base if seen[base] == 1 else '%s#%d' % (base, seen[base])

    # ── 第一层去重：题面哈希精确去重 ──
    # 留谁：公式已落成 LaTeX 的那版优先（公式图在组卷台里既搜不到也出不了类似题），
    #       再比题数多的卷，再比 id 短。
    latex_of = defaultdict(int)
    for q in qs:
        if '\\(' in (q.get('stem_markdown') or ''):
            latex_of[q['paper_external_id']] += 1

    def _rank(pid):
        return (-latex_of[pid], -n_by_paper[pid], len(pid), pid)

    qs.sort(key=lambda q: (_rank(q['paper_external_id']), q.get('sort_order') or 0))
    seen_sha, kept, n_dup = set(), [], 0
    for q in qs:
        h = q.get('content_sha256')
        if h and h in seen_sha:
            n_dup += 1
            continue
        if h:
            seen_sha.add(h)
        kept.append(q)

    # ⚠ 有的卷**整卷**被吸收（28 道题全部命中别卷的哈希）—— 那是同一场考试收在多个市目录里：
    #   福建/云南/新疆自 2017 起省统一命题（厦门=漳州=莆田=福州=省卷），江苏2009 那四市同卷。
    #   卷从选题器的「节」轴上消失是对的（题一道不少，只是不重复露面），
    #   但**不能无声消失** —— 列进报告，让人能核。
    absorbed = sorted(set(papers) - {q['paper_external_id'] for q in kept})

    # ── 省内节序号：年份倒序，同年按城市名 ──
    live = sorted({q['paper_external_id'] for q in kept},
                  key=lambda p: (papers[p].get('city') or '',
                                 -(papers[p].get('year') or 0),
                                 papers[p].get('district') or '', p))
    cs_of, per_prov = {}, defaultdict(int)
    for pid in live:
        prov = papers[pid].get('city') or '其他'
        per_prov[prov] += 1
        cs_of[pid] = per_prov[prov]

    data, fig_refs, leak, n_scr = [], {}, 0, 0
    asset_dir = os.path.join(B, 'assets')
    index = {}
    if os.path.isdir(asset_dir):
        with os.scandir(asset_dir) as it:
            for e in it:
                index[e.name.rsplit('.', 1)[0]] = e.name
    for q in kept:
        pid = q['paper_external_id']
        p = papers[pid]
        stem = _inject_tables(q.get('stem_markdown') or '', q.get('tables'))
        stem = _strip_lead_no(stem, q.get('question_no'))
        # 2026-09-09 视觉转录卷：几何图只留占位（裸 ⟦IMG⟧，无资产），卡片显示「（图略）」，与 zujuan_school/exam 同款
        stem = stem.replace('⟦IMG⟧', '（图略）')
        stem, _n = _unicode_scripts(stem)
        n_scr += _n
        ims = []
        for aid in IMG_RE.findall(stem):
            fn = index.get(aid)
            if not fn:
                continue
            key = 'cn/' + aid
            fig_refs[key] = os.path.join(asset_dir, fn)
            ims.append(key)
        # 自查零答案：门槛 ≥4 字（选择题答案就是「B」，题面必然有选项标号 B，短判必虚警）
        ans = str(q.get('answer_markdown') or '').strip()
        if len(ans) >= 4 and ans in stem:
            leak += 1
        yr = p.get('year') or 0
        data.append({
            'id': q['external_id'], 'tb': TB,
            'gg': p.get('city') or '其他', 'lv': 'CN%s' % (p.get('city') or '其他'),
            'cs': cs_of[pid], 'cd': label[pid],
            'src': '%d年' % yr, 's': '%d年' % yr,
            'n': q.get('question_no') or '', 'q': stem,
            't': _tnorm(q.get('question_type_raw')), 'd': 0,
            'f': '', 'mf': [], 'p': [], 'k': [], 'im': ims,
            # pid = 原卷 id，前端「📄 原卷」按钮拿它调本地助手；**只传 id 不传路径**
            'pid': pid,
        })

    # 课程树节点给 4 元：第 4 位是分组名（年份），前端渲成 <optgroup>，省内按年分组
    provs = sorted({d['gg'] for d in data},
                   key=lambda g: (-sum(1 for d in data if d['gg'] == g), g))
    course_sub = OrderedDict()
    for g in provs:
        nodes = OrderedDict()
        for x in data:
            if x['gg'] != g:
                continue
            nodes.setdefault(x['cs'], [x['cd'], 0, x['s'] or '其他'])
            nodes[x['cs']][1] += 1
        course_sub[g] = [[s, nd[0], nd[1], nd[2]] for s, nd in sorted(nodes.items())]

    # PIL 读不了的（清一色 .wmf）交给 LibreOffice 转 PNG 缓存。
    # ⚠ 这是**显示用**转换：源文件原样留在 data/approved/assets/，公式内容早已在录入期
    #   从 MTEF 源码解出。reference_libreoffice_rule 禁的是「拿渲染图去提取内容」。
    cache = os.path.join(HERE, '_wmf_cache')
    broken = []
    for key, fp in sorted(fig_refs.items()):
        if not fp.lower().endswith('.wmf'):
            continue
        try:
            with Image.open(fp) as im:
                im.load()
        except Exception:
            broken.append((key[3:], fp))
    if broken:
        if verbose:
            print('  [全国中考] PIL 读不了的图 %d 张（全是 wmf），交给 LibreOffice 转…' % len(broken))
        _rescue_wmf(broken, cache)

    FIG, skipped, rescued = {}, 0, 0
    for i, (key, fp) in enumerate(sorted(fig_refs.items()), 1):
        alt = os.path.join(cache, key[3:] + '.png')
        try:
            d64, W, H = _fig_b64(fp)
            FIG[key] = {'d': d64, 'w': W, 'h': H}
        except Exception:
            try:
                d64, W, H = _fig_b64(alt)
                FIG[key] = {'d': d64, 'w': W, 'h': H}
                rescued += 1
            except Exception:
                skipped += 1
        if verbose and i % 5000 == 0:
            print('  [全国中考] 配图 %d/%d' % (i, len(fig_refs)))

    # 仍嵌不进去的图**不能悄悄消失**：留显式记号，别给出「求 的值」这种看着完整的残题
    n_hole = 0
    for x in data:
        miss = [k for k in x['im'] if k not in FIG]
        if not miss:
            continue
        for k in miss:
            x['q'] = re.sub(r'!\[[^\]]*\]\(asset://%s\)' % re.escape(k[3:]), '⟦图缺失⟧', x['q'])
        x['im'] = [k for k in x['im'] if k in FIG]
        n_hole += 1

    report = {'source': B,
              'questions_with_missing_figure': n_hole,
              'answer_polluted_stems_excluded': sorted(polluted),
              'exact_dup_questions_dropped': n_dup,
              'papers_fully_absorbed_as_duplicates': [
                  {'pid': x, 'title': papers[x].get('title'),
                   'city': papers[x].get('city'), 'year': papers[x].get('year'),
                   'district': papers[x].get('district')} for x in absorbed]}
    with open(os.path.join(HERE, '_全国中考并库报告.json'), 'w', encoding='utf-8') as f:
        json.dump(report, f, ensure_ascii=False, indent=1)

    if verbose:
        print('  [全国中考] 卷 %d 张 / %d 题（同题面去重 -%d 题）；省份 %d 个'
              % (len(live), len(data), n_dup, len(provs)))
        if absorbed:
            print('  [全国中考] 另有 %d 张卷**整卷**与别卷重复（省统一命题收在多个市目录里），'
                  '题一道不少、只是不重复露面 → 清单见 _全国中考并库报告.json'
                  % len(absorbed))
        print('  [全国中考] 配图 %d 张（LibreOffice 救回 %d，仍失败 %d → %d 道题标⟦图缺失⟧），'
              'FIG %.1f MB；答案泄漏自查 %d 条；裸上下标转 Unicode %d 处；'
              '小问 -%d 条；题干被污染剔除 -%d 条'
              % (len(FIG), rescued, skipped, n_hole, len(json.dumps(FIG)) / 1e6,
                 leak, n_scr, n_sub, len(polluted)))
    return data, course_sub, FIG


if __name__ == '__main__':
    d, c, f = load(os.path.dirname(os.path.abspath(__file__)))
    print('自测：', len(d), '题', len(c), '个省份', len(f), '图')
    if d:
        print('样例：', json.dumps({k: v for k, v in d[0].items() if k != 'q'},
                                   ensure_ascii=False))
