#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把「组卷台的专题节」对到「_专题拆分/ 里的成品文件」，产出 _组卷台映射.json。

## 为什么要单独一步
组卷台的专题名（COURSE 树里的 cd）和拆分文件名不是同一套写法：
    组卷台  「第20讲·对策问题」   拆分版 「动脑筋/三年级/三年级下/2.对策问题.pdf」
    组卷台  「计算（一）·整数加减法巧算」→ 胡小群同一个节下**同时有**讲义和课后两个文件
所以先离线把 1920 个节逐个对上号、把对不上的**打印出来**（不静默漏），
服务端运行时只做一次字典查表，零猜测。

## 权威来源
组卷台的 COURSE 树直接从 组卷台.html 里抠（就是下拉框真正会送上来的 tb/gg/cd 三元组），
不靠重新推导 —— 避免「我以为的专题名」和「页面上的专题名」对不上。

用法：python3 build_precut_map.py [组卷台.html]
"""
import os, re, sys, json, glob, collections

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, '源题文件')
OUT = os.path.join(SRC, '_专题拆分')


def norm(s):
    """比名字用：去空白/全半角括号差异/标点，只留可比的核心。"""
    s = str(s or '')
    s = re.sub(r'[（(]', '(', s); s = re.sub(r'[）)]', ')', s)
    s = s.replace('：', ':').replace('，', ',')
    return re.sub(r'[\s　·、]', '', s)


def load_course(html_path):
    """从组卷台 html 里抠 COURSE 树（tb → gg → [[cs, cd, n, (group)]…]）。"""
    sz = os.path.getsize(html_path)
    with open(html_path, encoding='utf-8') as f:
        f.seek(max(0, sz - 200_000_000))
        buf = f.read()
    i = buf.find('const DATA=[')
    j = buf.find(', COURSE=', i)
    if i < 0 or j < 0:
        raise SystemExit('未在 html 里找到 COURSE')
    return json.JSONDecoder().raw_decode(buf, j + len(', COURSE='))[0]


def load_manifest():
    d = json.load(open(os.path.join(OUT, '_索引.json'), encoding='utf-8'))['items']
    by = collections.defaultdict(list)
    for x in d:
        by[x['tb']].append(x)
    return by


# 一课一练：组卷台 gg「三年级上·增强版2022秋」 ↔ 拆分目录「三年级上增强2022秋」
def ykl_gg2dir():
    m = {}
    for bd in sorted(glob.glob(os.path.join(ROOT, '成果', '一课一练题库', '*'))):
        book = os.path.basename(bd)
        f = next((p for p in glob.glob(os.path.join(bd, '一课一练_结构化题目*.json'))), None)
        if not f:
            continue
        try:
            q = json.load(open(f, encoding='utf-8'))[0]
        except Exception:
            continue
        gg = f"{q.get('grade','')}·{q.get('edition','')}{q.get('year','')}"
        m[gg] = book
    return m


HXQ_LV = {'一·二年级': 'L1-2', '三年级': 'L3', '四年级': 'L4', '五年级': 'L5', '六年级': 'L6'}

# ── 胡小群 crosswalk：讲义(按学期分册) ↔ 课后(课程节脊)，与 gen_组卷台.py 同一套规则 ──
# ⚠ 这里**必须复用题库自己的对应关系，不能按标题名字猜**。踩过：
#   「行程进阶（一）」的讲义文件叫 `14.行程进阶.pdf`（讲义把上半学期的「（一）」省了），
#   「环形相遇及追及」的课后叫「环形相遇**与**追及」（与/及一字之差），
#   名字匹配全落空 → 那 5 个节只挂上课后、讲义悄悄丢了，而且**不报错**（回退给了课后）。
HXQ_BOOK_LV = {'三上': 'L3', '三下': 'L3', '四上': 'L4', '四下': 'L4',
               '五上': 'L5', '五下': 'L5', '六上': 'L6', '六下': 'L6'}
HXQ_OVERRIDE = {('三上', 15): '周期问题（一）', ('四上', 14): '行程进阶（一）',
                ('五上', 15): '最值原理进阶（一）', ('五下', 12): '分数概念与性质'}


def _hnorm(t):
    t = re.sub(r'[：:].*$', '', str(t))
    t = t.replace('与', '及')                      # ⚠ 「环形相遇与/及追及」靠这条抹平
    return re.sub(r'\s+', '', t).strip()


def hxq_crosswalk():
    """→ {(lv, csec): {'讲义': (册, 讲号), '课后': (lv, 节号)}}

    脊 = 课后习题的节序列；讲义按规范标题归到脊上（与组卷台 course_sec 完全一致）。
    """
    p = os.path.join(ROOT, '成果', '胡小群题库', '胡小群题库_结构化题目.json')
    d = json.load(open(p, encoding='utf-8'))
    items = d.get('items') if isinstance(d, dict) else d

    def lv_of(q):
        bk = q.get('book', '')
        if bk == '1-2年级课后习题': return 'L1-2'
        if bk == '': return q.get('grade')          # L3~L6 课后
        return HXQ_BOOK_LV.get(bk)                  # 讲义

    def sec_int(q):
        try: return int(str(q.get('sec', '99')))
        except Exception: return 99

    spine = {}
    out = collections.defaultdict(dict)
    for q in items:                                  # 先建脊（只用课后题）
        if q.get('source') == '胡小群讲义': continue
        lv, s = lv_of(q), sec_int(q)
        if not lv: continue
        spine.setdefault(lv, {}).setdefault(_hnorm(q.get('sec_title', '')), s)
        out[(lv, s)]['课后'] = (lv, s)
    for q in items:                                  # 再把讲义挂到脊上
        if q.get('source') != '胡小群讲义': continue
        bk, s, lv = q.get('book', ''), sec_int(q), lv_of(q)
        cn = _hnorm(HXQ_OVERRIDE.get((bk, s), q.get('sec_title', '')))
        csec = (spine.get(lv) or {}).get(cn)
        if csec is None: continue
        out[(lv, csec)]['讲义'] = (bk, s)
    return out


def build(course, man):
    """→ {'tb|gg|cd': [{'label':…,'path':…}…]}，外加未命中清单。"""
    out, miss = {}, []
    how, fallback = collections.Counter(), []
    ykl_dir = ykl_gg2dir()
    xwalk = hxq_crosswalk()

    # 预建按册的名字索引
    # ⚠ 现在同一个专题有**学生版和教师版两条**，索引必须带 role，否则后写的把前一条盖掉
    idx = collections.defaultdict(dict)          # (tb, book, role) → {norm(title): item}
    idxno = collections.defaultdict(dict)        # (tb, book, role) → {no: item}
    for tb, items in man.items():
        for x in items:
            r = x.get('role', '学生版')
            idx[(tb, x['book'], r)][norm(x['title'])] = x
            if x.get('no'):
                idxno[(tb, x['book'], r)].setdefault(x['no'], x)

    def both(getter):
        """同一个专题取学生版+教师版 → [{'role','path'}…]，学生版排前面（默认给学生的那份）"""
        out = []
        for r in ('学生版', '教师版'):
            x = getter(r)
            if x:
                out.append({'label': r, 'role': r, 'path': x['out']})
        return out

    def books_of(tb, pred):
        return sorted({b for (t, b, _r) in idx if t == tb and pred(b)})

    for tb, grades in course.items():
        for gg, nodes in grades.items():
            for node in nodes:
                cs, cd = node[0], node[1]
                hits = []

                if tb == '初中培优讲义':
                    hits = both(lambda r: idx[(tb, gg, r)].get(norm(cd)))

                elif tb == '一课一练':
                    bk = ykl_dir.get(gg)
                    if bk:
                        x = idx[(tb, bk, '学生版')].get(norm(cd))
                        if x:
                            how[(tb, '名字')] += 1
                        else:                        # 名字对不上就按课次序号——**记账，不静默**
                            x = idxno[(tb, bk, '学生版')].get(cs)
                            if x: how[(tb, '序号兜底')] += 1; fallback.append((tb, gg, cs, cd))
                        if x: hits = [{'label': '学生版', 'role': '学生版', 'path': x['out']}]
                        # 答案是整本连排在末尾、不按课分 → 全册一份当教师版
                        # ⚠ 不能用 idxno 取：它的 no=0，而建索引时 `if x.get('no')` 把 0 当假值跳过了
                        ans = idx[(tb, bk, '教师版')].get(norm('全册参考答案'))
                        if ans: hits.append({'label': '全册答案', 'role': '教师版', 'path': ans['out']})

                elif tb in ('举一反三', '动脑筋'):
                    # ⚠ **按序号定位，名字只做核对** —— 不能反过来。
                    #   动脑筋上册和下册各有一套「期初复习/期中练习（一）/期末检测（二）」，
                    #   按名字找会跨册认岔（踩过：17 个专题指到了另一学期，页数一比就露馅）。
                    #   拆分版上/下各自从 1 编号，组卷台是全年 1~38 连号 →
                    #   把「上册1~N + 下册接着数」拼成整年序列，第 cs 个就是它。
                    cands = books_of(tb, lambda b: gg in b)
                    seq = []
                    for b in sorted(cands):
                        k = (tb, b, '学生版')
                        seq += [idxno[k][n] for n in sorted(idxno[k])]
                    x = seq[cs - 1] if 1 <= cs <= len(seq) else None
                    if x:
                        hits = [{'label': '学生版', 'role': '学生版', 'path': x['out']}]
                        for b in sorted(cands):      # 参考答案是整册一份，不按专题
                            a = next(iter(idx[(tb, b, '教师版')].values()), None)
                            if a:
                                hits.append({'label': '整册答案', 'role': '教师版', 'path': a['out']}); break
                        name = norm(re.sub(r'^(专题\d+|第\d+讲)[·]?', '', cd))
                        fn = norm(x['title'])
                        if name and fn and not (name in fn or fn in name):
                            how[(tb, '序号·名字存疑')] += 1
                            fallback.append((tb, gg, cs, cd, '文件=' + x['title']))
                        else:
                            how[(tb, '序号·名字相符')] += 1

                elif tb == '胡小群课程':
                    lv = HXQ_LV.get(gg)
                    cw = xwalk.get((lv, cs)) or {}
                    # ⚠ 讲义放前面：组卷台 data 就是讲义排在课后前，前端取第一道题的 src
                    #   送上来，默认值必须和它一致，否则不传 src 时给的文件会对不上。
                    jy_book = None
                    if cw.get('讲义'):
                        b, s = cw['讲义']
                        x = idxno[('胡小群讲义', b, '学生版')].get(s)
                        if x:
                            hits.append({'label': '讲义', 'role': '学生版', 'path': x['out']})
                            jy_book = b
                    if not cw.get('讲义') and lv != 'L1-2':
                        # ⚠ crosswalk 是按**题**建的，某一讲一道题都没提取到就不在里面
                        #   （五上第2讲「数的整除特征（二）」就是这样）——但讲义 PDF 是有的，
                        #   老师要的是源文件，不能因为题库没题就不给 → 按标题补位。
                        for b in HXQ_BOOK_LV:
                            if HXQ_BOOK_LV[b] != lv:
                                continue
                            for it in man.get('胡小群讲义', []):
                                if it.get('role', '学生版') == '学生版' and it['book'] == b and _hnorm(it['title']) == _hnorm(cd):
                                    hits.append({'label': '讲义', 'path': it['out']})
                                    jy_book = b
                                    how[(tb, '讲义按名补位')] += 1
                                    break
                            if hits:
                                break
                    # 讲义练习的答案统一印在册尾「参考答案」上（整册一份，含本册全部 15 讲），
                    # 跟在讲义后面挂给这一册的每个专题。见 split_topics.do_hxq_lecture_key。
                    if jy_book:
                        ka = idx[('胡小群讲义答案', jy_book, '教师版')].get(norm('参考答案'))
                        if ka:
                            # 标签必须写明「全册」：这一份含本册 15 讲的全部答案，
                            # 不是只有当前这一讲，别让老师以为发出去只泄一讲的答案。
                            hits.append({'label': '讲义答案（全册）', 'role': '教师版',
                                         'path': ka['out']})
                    if cw.get('课后'):
                        _, s = cw['课后']
                        hb = 'L1-2' if lv == 'L1-2' else lv
                        for r, lab in (('学生版', '课后'), ('教师版', '课后答案')):
                            x = idxno[('胡小群课后', hb, r)].get(s)
                            if x: hits.append({'label': lab, 'role': r, 'path': x['out']})

                if tb in ('初中培优讲义', '胡小群课程'):
                    how[(tb, '题库对应')] += 1
                if hits:
                    out[f'{tb}|{gg}|{cd}'] = hits
                else:
                    miss.append((tb, gg, cs, cd))
    return out, miss, how, fallback


if __name__ == '__main__':
    html_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, '组卷台.html')
    course = load_course(html_path)
    man = load_manifest()
    m, miss, how, fallback = build(course, man)
    tot = sum(len(v) for v in course.values() for v in [v]) if False else \
        sum(len(n) for g in course.values() for n in g.values())
    print(f'组卷台专题节 {tot} 个 → 对上 {len(m)} 个，未对上 {len(miss)} 个')
    per = collections.Counter(k.split('|')[0] for k in m)
    for tb, g in course.items():
        n = sum(len(v) for v in g.values())
        print(f'  {tb:<12} {per.get(tb,0)}/{n}')
    print('\n匹配方式:')
    for (tb, k), n in sorted(how.items()):
        print(f'  {tb:<12}{k:<10}{n}')
    if fallback:
        print(f'\n⚠ 走了序号兜底（名字没对上，须另用页数等独立口径复核）共 {len(fallback)} 条:')
        for r in fallback[:15]:
            print('   ', r)
        if len(fallback) > 15:
            print(f'    … 另有 {len(fallback)-15} 条')
    if miss:
        print('\n未对上（不静默跳过）:')
        for r in miss[:40]:
            print('   ', r)
        if len(miss) > 40:
            print(f'    … 另有 {len(miss)-40} 条')
    p = os.path.join(OUT, '_组卷台映射.json')
    json.dump({'n': len(m), 'map': m, 'miss': miss}, open(p, 'w', encoding='utf-8'),
              ensure_ascii=False, indent=1)
    print('\n→', os.path.relpath(p, ROOT))
