#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""组卷台「出同类题」的第三条腿：学科网真题。

薄封装，只做三件事：调接口、把返回归一成组卷台的 item 形状、把公式图内嵌成 base64。
签名/凭证全交给 `搜题/xkw.py`（凭证只从 ~/.secrets/xkw_open_api 读，不进日志）。

## 为什么不先 text-search 定位 question_id
交接里写「要 10 道就先 text-search 拿 id 再走 v2」——**其实不必**。查 swagger（sg12）：
    similar-recommend v1    `text`          题干文本，与 question_id 二选一，count 默认 5
    ai-similar-recommend    `question_text` 题干文本 / `question_image` 题干图片 / question_id
                            优先级 试题ID > 题干图片 > 题干文本，count ≤ 10
两条都能直接吃题面文本。实测 v1 0.5 秒出 5 道、ai 4.6 秒出 10 道，
省掉一次调用，也避开了「题面定位不到就没结果」那个已知盲区。
（v2 才是只认 question_id。）

## 授权红线（搜题/CLAUDE.md）
学科网是**授权内容不是自有内容**：可实时调用、可展示、可给老师参考，
但 **不落库、不去水印、不并进零答案题库**。所以：
- 抽屉里**原样渲染返回的 HTML**（`<!--版权声明-->` 注释和 `data-copyright` 水印都在，没剥）；
- 只缓存公式图和 question_id，不缓存题面正文。
⚠ 卷面/屏幕上的**可见**版权字样，用户 2026-07-27 明确要求全部去掉，已照办
  （那些是我额外加的文字；真水印是 HTML 属性和注释，不渲染不打印，一个字节没动）。
  **别再自作主张加回去。**
"""
import os, re, sys, json, base64, datetime, urllib.request, concurrent.futures

HERE = os.path.dirname(os.path.abspath(__file__))
_SEARCH = os.path.normpath(os.path.join(HERE, '..', '搜题'))
if _SEARCH not in sys.path:
    sys.path.insert(0, _SEARCH)

try:
    import xkw                                   # 搜题/xkw.py
    READY, ERR = True, ''
except Exception as e:                           # 拿不到就整体降级，服务照跑
    xkw, READY, ERR = None, False, str(e)

V1 = '/xopqbm/questions/similar-recommend'
AI = '/xopqbm/questions/ai-similar-recommend'
PREDICT = '/xopqbm/ai-tag/predict-kpoint'
PUSH = '/xopqbm/questions'

# 课外奥数 vs 校内同步 —— 决定走哪条出题链路（小克 2026-07-28 实测结论）
OLYMPIAD_TB = {'胡小群课程', '动脑筋', '举一反三'}
# 难度带按场景分：同一个知识点在不同难度带给的是两种东西
#   （#66485 在 19 档出「量长度算周长」，在 20/21 档出「五角星角度和」——
#    选错难度带比选错知识点更致命）
LV_OLYMPIAD = [20, 21]          # 较难 + 困难
LV_SCHOOL = [19, 20, 21]        # 一般 + 较难 + 困难

# ── 难度只能在本地滤（小克 2026-07-28 结论）─────────────────────────────
# `difficulty_levels` 是**请求参数**，只有 /questions 和 v1 收，v2/ai 都不收；
# 主用的 ai 收不了 → 只能把题拿回来按返回字段 `difficulty` 自己滤。
# ⚠ `difficulty` 是**得分率**，**越大越简单**（不是难度分，别搞反）。
#   档位边界取自 /xopqbm/question-difficulties（2026-07-28 实拉）：
#     17容易 .90~.99 / 18较易 .80~.90 / 19一般 .50~.80 / 20较难 .30~.50 / 21困难 .00~.30
DIFF_MAX_OLYMPIAD = 0.50        # 奥数只要 20 较难 + 21 困难
DIFF_MAX_SCHOOL = 0.80          # 校内要 19 一般 + 20 较难 + 21 困难
# ⚠ `difficulty` 是**档位中点**，不是连续得分率：100 道实测只出现 4 个值
#   0.15(困难) / 0.40(较难) / 0.65(一般) / 0.85(较易)。
#   所以值永远落不到 0.50 / 0.80 这两个阈值上，**没有边界归属问题**，阈值不用再调。
DIFF_NAME = {17: '容易', 18: '较易', 19: '一般', 20: '较难', 21: '困难'}


def _diff_ok(q, tb):
    """这道题的难度合不合格。**缺 difficulty 字段按不通过处理，别默认放行。**

    为什么奥数要卡到 0.50：19「一般」这档对奥数就是课内水平 —— 同一个知识点
    #66485「长度和角度」，19 档出的是「量长度算周长」，20/21 档出的是
    「五角星角度和」，节点名一模一样，题差一个量级。
    """
    d = q.get('difficulty')
    try:
        d = float(d)
    except (TypeError, ValueError):
        return False
    return d <= (DIFF_MAX_OLYMPIAD if tb in OLYMPIAD_TB else DIFF_MAX_SCHOOL)


COPYRIGHT_NOTE = '试题由学科网授权提供，受版权保护'

# 竞赛分支挂掉时给老师看的话。
# ⚠ 别只写「已退回相似题」—— 那只说了换接口，没说后果。真正丢的是**难度保证**：
#   竞赛分支靠 `difficulty_levels` 把结果锁在 20 较难 + 21 困难，
#   退回相似题后 API 侧一个难度旋钮都不剩（v2/ai 根本不收这个参数），
#   只能在本地按得分率滤，滤不掉「看着难其实浅」的题。（小克 2026-07-28）
_DEGRADE = ('（奥数难度链路不可用，已退回相似题；结果按得分率做了本地过滤，'
            '**未经竞赛难度带筛选，请人工过一遍**）')

# ── 日闸：取题类接口一天最多打这么多次 ──────────────────────────────────
# 判据：每道种子最多 3 次调用 → 200 次 ≈ 66 道种子/天，单机构日常出卷绰绰有余，
# 而离「393 次烧穿一个资源包」还有安全距离。
# 账本直接读 xkw.py 自己记的 data/usage.jsonl（只记时间/路径/条数，不记题面不记参数）。
DAY_CAP = 200
_FETCH_PATHS = ('/questions/similar-recommend', '/questions/v2/similar-recommend',
                '/questions/ai-similar-recommend', '/questions/text-search',
                '/questions/tiku-search')


def day_used():
    """今天取题类接口已经打了几次。读不到账本就当 0（不因为统计失败卡住出题）。"""
    p = os.path.join(_SEARCH, 'data', 'usage.jsonl')
    today = datetime.date.today().isoformat()
    n = 0
    try:
        with open(p, encoding='utf-8') as f:
            for line in f:
                try:
                    r = json.loads(line)
                except Exception:
                    continue
                if str(r.get('t', ''))[:10] != today:   # ⚠ 字段名是 t 不是 ts
                    continue
                # 报错的那次不算 —— 一次网络抖动不该白吃配额（小克 2026-07-28）
                if r.get('err'):
                    continue
                if any(r.get('path', '').endswith(s) for s in _FETCH_PATHS):
                    n += 1
    except FileNotFoundError:
        return 0
    except Exception:
        return 0
    return n

# 课程 id：小学数学 2 / 初中数学 11 / 高中数学 27
#
# 🔴 **不能只看年级字样判**（踩过：六年级的圆与扇形题返回了抛物线题）。
#    「六年级」在两套体系里是两棵完全不同的树：
#      胡小群 L6 / 动脑筋 / 举一反三 六年级 = **小学奥数** → 小学数学(2)
#      初中培优讲义 六上六下、一课一练六年级 = **上海五四制初中预备** → 初中数学(11)
#    搜题/CLAUDE.md 只写了后者那条（「上海六年级走 初中数学 → 五四制小学衔接」），
#    我照搬到胡小群头上 → 开了初中数学的口径，二次函数/抛物线就都进了候选池。
TB_COURSE = {'胡小群课程': 2, '动脑筋': 2, '举一反三': 2, '初中培优讲义': 11}
_JUNIOR = re.compile(r'初|六年级|六上|六下|[七八九]|\b[789]\b')
_SENIOR = re.compile(r'高[一二三中]')


def course_id(grade, tb=''):
    """→ 学科网 course_id。**教材体系优先**，年级字样只在教材未知时兜底。"""
    tb = str(tb or '').strip()
    if tb in TB_COURSE:
        return TB_COURSE[tb]
    g = str(grade or '')
    if tb == '一课一练':                    # 上海一课一练：六年级起是五四制初中预备
        return 11 if re.search(r'六年级|[七八九]年级', g) else 2
    if _SENIOR.search(g):
        return 27
    if _JUNIOR.search(g):
        return 11
    return 2


# 小学数学「数学竞赛」子树快照（160 节点）。
# ⚠ `搜题/知识树/小学数学.json` 那份**竞赛分支是空的**（1303 节点里该支只有根节点，
#   子树没拉下来），所以只能另存一份：`/knowledge-points?course_id=2&tags=CONTEST`。
_CONTEST_FILE = os.path.join(ROOT_SEARCH := _SEARCH, '知识树', '小学数学_竞赛分支.json')
# ⚠ 只剥「（一）（2）」这类序号和分隔符，**别剥「问题」「初步」这些后缀**：
#   剥了「植树问题」会变成 2 字的「植树」，既丢辨识度又会被长度守卫挡掉（踩过）。
#   「与/和」要抹平：组卷台叫「圆与扇形」，竞赛树里叫「圆和扇形」。
_NOISE = re.compile(r'[（(][^（()）]*[)）]|[·・\s]')


def _norm_kp(x):
    return _NOISE.sub('', str(x or '')).replace('与', '和')


def _contest_index():
    """{规范化节点名: kpoint_id} —— 专题名直接查竞赛知识点，确定性、零 API 调用。"""
    if 'ci' not in _CACHE:
        idx = {}
        try:
            d = json.load(open(_CONTEST_FILE, encoding='utf-8'))
        except Exception:
            d = []

        def walk(ns):
            for n in ns or []:
                nm = _norm_kp(n.get('name'))
                if nm and n.get('id'):
                    idx.setdefault(nm, n['id'])
                walk(n.get('children') or [])
        walk(d)
        _CACHE['ci'] = idx
    return _CACHE['ci']


_CACHE = {}


# 专题名 ↔ 竞赛节点名 对不上的那些（全年级审查实测的 17 个漏网，按 160 个节点名逐个核过）。
# ⚠ 只列**有把握**的：映错节点比退回相似题更糟——相似题至少还贴着原题题面，
#   而映错知识点会推出完全另一个题型的题。
#   「期初复习/期中练习/取钱币/巧画线段」这类竞赛树里本来就没有，**故意不映**。
TOPIC_ALIAS = [
    ('加减法巧算', '加减法的速算与巧算'), ('加减巧算', '加减法的速算与巧算'),
    ('乘法性质', '乘除法的速算与巧算'), ('乘除巧算', '乘除法的速算与巧算'),
    ('乘法巧算', '乘除法的速算与巧算'), ('整数乘除法巧算', '乘除法的速算与巧算'),
    ('小数乘法的巧算', '小数的速算与巧算'), ('小数巧算', '小数的速算与巧算'),
    ('分数巧算', '分数的速算与巧算'), ('计算综合', '四则混合运算的速算与巧算'),
    ('奇数与偶数', '数的奇偶性'), ('奇偶', '数的奇偶性'),
    ('组合图形', '巧求面积'), ('直线型计算', '巧求面积'), ('阴影', '巧求面积'),
    ('计算圆的面积', '圆和扇形'), ('圆的面积', '圆和扇形'), ('扇形', '圆和扇形'),
    ('消去问题', '等量代换'), ('消元', '等量代换'),
    ('简单判断', '简单推理'), ('逻辑判断', '简单推理'),
    ('智力趣题', '趣题巧解'), ('趣题', '趣题巧解'),
    ('古典概型', '统计与概率'), ('可能性', '统计与概率'), ('概率', '统计与概率'),
    ('数线段', '平面图形计数'), ('数图形', '平面图形计数'), ('图形计数', '平面图形计数'),
    ('裂项', '分数裂项'), ('周长', '巧求周长'),
]


def _by_topic(topic):
    """专题名 → 竞赛知识点 id。「年龄问题」「植树问题」「等差数列」这些**就是竞赛树的节点名**。"""
    t = _norm_kp(re.sub(r'^(专题\d+|第\d+[讲节])[·]?', '', topic or ''))
    if not t:
        return []
    idx = _contest_index()
    if t in idx:
        return [idx[t]]
    hit = [v for k, v in idx.items() if (t in k or k in t) and len(k) >= 3]
    if hit:
        return hit[:10]
    for kw, node in TOPIC_ALIAS:            # 叫法对不上的走别名表
        # ⚠ 别名 key 也要过同一套归一化：t 里的「与」已经换成「和」了，
        #   拿没归一化的「奇数与偶数」去比会永远落空（踩过）
        if _norm_kp(kw) in t:
            v = idx.get(_norm_kp(node))
            if v:
                return [v]
    return []


def _competition_kpoints(text, cid, topic='', allow_predict=False):
    """→ 竞赛分支知识点 id。**只按专题名查树（确定性、零 API）。**

    `allow_predict=True` 才会退到 `ai-tag/predict-kpoint` —— 出题主链路一律不开，
    留给「老师手输一道题」那种我们完全不知道它考什么的入口。

    ⚠ 只靠 `ai-tag/predict-kpoint` 命中率只有 28%（52 道全年级审查实测）——
      它对几何题会给竞赛节点，但「年龄问题/植树问题/等差数列/容斥原理」这些
      典型奥数题型它只给课标节点。而这些名字**在竞赛树里逐字都有**。
    """
    got = _by_topic(topic)
    if got:
        return got
    if not allow_predict:
        # 小克 2026-07-28：predict-kpoint 别放进出题主链路 —— 3.8 秒、AI 后端、大概率计费，
        # 而组卷台的种子来自自建库，知识点我们自己就有（胡小群知识图谱），
        # 不需要花钱让学科网猜。只在「老师手输一道题、完全不知道它考什么」时才开。
        return []
    try:
        r = xkw.call(PREDICT, body={'course_id': cid, 'text': (text or '')[:2000]})
    except Exception:
        return []
    ids = []
    for x in (r.get('data') or []):
        path = [p.get('name', '') for p in reversed(x.get('path') or [])]
        if '数学竞赛' in path and x.get('kpoint_id'):
            ids.append(x['kpoint_id'])
    return ids[:10]                      # 文档：kpoint_ids 最多 10 个


class PushError(Exception):
    pass


def _push(cid, kps, levels, want):
    """知识点推题 + `session_id` 连推去重 —— **唯一能上量的入口**（相似题一次最多 5~10 道）。

    ⚠ 去重按**题面指纹**不按 id（海量版 id 是一次性 token，同题两次请求 id 不同）。
    """
    got, seen, sid = [], set(), None
    for _ in range(6):
        need = want - len(got)
        if need <= 0:
            break
        body = {'course_id': cid, 'kpoint_ids': kps, 'difficulty_levels': levels,
                'count': min(10, max(1, need)), 'formula_pic_format': 'png',
                'catalog_ids': [], 'type_ids': [], 'area_ids': []}
        if sid:
            body['session_id'] = sid
        try:
            r = xkw.call(PUSH, body=body)
        except Exception as e:
            # ⚠ **别把错误吞成 0 道**：吞掉之后表现成「查到知识点却推不出题」，
            #   看起来像功能倒退，实际是额度用尽（402 资源包不足）。今天就误诊过一次。
            raise PushError(('学科网推题额度已用尽（402 资源包不足），请联系学科网加包'
                             if '402' in str(e) else '推题失败：%s' % str(e)[:100]))
        sid = r.get('session_id') or sid
        fresh = 0
        for q in (r.get('data') or []):
            fp = _TAG.sub('', q.get('stem') or '')[:200]
            if fp in seen:
                continue
            seen.add(fp); got.append(q); fresh += 1
        if not fresh:                    # 连推不出新的了，别空转
            break
    return got[:want]


# ── 公式图：只缓存图，不缓存题面正文 ──────────────────────────────
_IMG_CACHE = {}
_IMG = re.compile(r'<img\b[^>]*?\bsrc="([^"]+)"[^>]*>', re.I)
_TAG = re.compile(r'<[^>]+>')
_COMMENT = re.compile(r'<!--.*?-->', re.S)


def _fetch_img(url):
    if url in _IMG_CACHE:
        return _IMG_CACHE[url]
    try:
        with urllib.request.urlopen(url, timeout=15) as r:
            raw = r.read()
        if len(raw) > 400_000:                   # 异常大的图不内嵌，退回原链接
            _IMG_CACHE[url] = None
        else:
            mime = 'image/png' if url.lower().endswith('.png') else 'image/svg+xml'
            _IMG_CACHE[url] = 'data:%s;base64,%s' % (mime, base64.b64encode(raw).decode())
    except Exception:
        _IMG_CACHE[url] = None
    return _IMG_CACHE[url]


def _to_export_md(html, pool=None):
    """HTML → 导出用文本：公式图内嵌成 `![](data:image/png;base64,…)`，其余标签去掉。

    注：这一步会丢掉 `data-copyright` 属性（标签都去了）。曾在卷末补印版权声明，
    但用户明确要求卷面不出现任何版权字样，已去掉——见模块开头说明。
    """
    urls = _IMG.findall(html or '')
    if urls and pool:
        list(pool.map(_fetch_img, dict.fromkeys(urls)))   # 预热缓存，去重后并发拉

    def rep(m):
        d = _fetch_img(m.group(1))
        return '![](%s)' % d if d else '[公式图]'

    s = _IMG.sub(rep, html or '')
    s = _COMMENT.sub('', s)
    s = re.sub(r'</(p|div|br)>', '\n', s, flags=re.I)
    s = _TAG.sub('', s)
    s = s.replace('&nbsp;', ' ').replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
    return re.sub(r'\n{3,}', '\n\n', s).strip()


def _norm(q, pool):
    """学科网返回 → 组卷台 item 形状。

    `stem_html` 给抽屉原样渲染（水印完整）；`stem` 给导出（内嵌公式图，无版权字样）。
    """
    stem_html = q.get('stem') or ''
    return {
        'type': ((q.get('type') or {}).get('name') or '真题'),
        'stem_html': stem_html,                        # 展示用：**原样 HTML，水印不动**
        'stem': _to_export_md(stem_html, pool),        # 导出用：公式图内嵌
        'answer_html': q.get('answer') or '',
        'explanation_html': q.get('explanation') or '',
        'difficulty': q.get('difficulty_level'),       # 17容易…21困难
        # 给老师看档位名，别把 difficulty 那个浮点数摆出来（小克 2026-07-28）：
        # 它是**档位中点**不是连续得分率，100 道只出现 4 个值 0.15/0.40/0.65/0.85，
        # 显示「0.6499999761581421」既没意义又像 bug。
        'difficulty_name': DIFF_NAME.get(q.get('difficulty_level'), ''),
        'kpoints': [k.get('name') for k in (q.get('kpoints') or [])],
        'xid': str(q.get('id') or ''),                 # 只缓存 id，不缓存正文
        'by': 'xkw', 'verified': True,                 # 真题，不需要数值核验
        'copyright': COPYRIGHT_NOTE,
    }


def similar(stem, grade='', n=5, mode='fast', image_b64=None, levels=None, tb='', topic='',
            stats=None):
    """→ (items, 说明)。mode: fast=v1(0.5秒5道) / more=ai(4.6秒10道) / image=ai传图。
    tb = 教材名，**决定走哪棵课程树**，务必传（不传会退回按年级字样猜，六年级会猜错）。

    `stats` 传个 dict 进来的话会被填上 {'got':接口给了几道, 'kept':难度过关几道}，
    调用方据此区分「接口没题」和「题都太浅」—— 后者要按种子降级，不是按教材封杀。
    """
    if stats is None:
        stats = {}
    stats.update(got=0, kept=0)
    if not READY:
        return [], '学科网客户端不可用：%s' % ERR[:120]
    used = day_used()
    if used >= DAY_CAP:
        return [], ('学科网今日取题已达上限（%d/%d 次），明天自动恢复。'
                    '现在请改用「生成器」或「AI」出题。' % (used, DAY_CAP))
    cid = course_id(grade, tb)
    note = ''

    # ── 奥数走「竞赛分支知识点 + 推题」，不用相似题 ──
    # 实测相似题对奥数**两头不讨好**：课标口径给「圆形茶几桌面直径」这种课内基础题，
    # 初中口径又会串出抛物线。竞赛节点给的才是分针转动/圆盘滚动/翻滚这类奥数路数。
    if tb in OLYMPIAD_TB and not image_b64 and mode != 'image' and not _CACHE.get('push_dead'):
        kps = _competition_kpoints(stem, cid, topic)
        if kps:
            try:
                raw = _push(cid, kps, LV_OLYMPIAD, n)
            except PushError as e:
                # 402 是**这条接口的资源包**耗尽（学科网按接口分开卖），不是参数问题，
                # 也不会自己好。记下来，本进程后面就别再白打了 —— 每打一次都是一次调用。
                if '402' in str(e) or '资源包' in str(e):
                    _CACHE['push_dead'] = True
                raw, note = [], _DEGRADE
            else:
                note = ''
            if raw:
                raw = [q for q in raw if _diff_ok(q, tb)]
            if raw:
                with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
                    return [_norm(q, pool) for q in raw], '竞赛分支 · %d 道' % len(raw)
        if not note:
            note = '（没找到竞赛知识点，退回相似题）'
    elif tb in OLYMPIAD_TB and _CACHE.get('push_dead'):
        note = _DEGRADE

    if image_b64 or mode == 'image':
        path = AI
        body = {'course_id': cid, 'count': min(10, n), 'formula_pic_format': 'png'}
        if image_b64:
            body['question_image'] = image_b64
        else:
            body['question_text'] = stem
    elif mode == 'more':
        path, body = AI, {'course_id': cid, 'question_text': stem,
                          'count': min(10, n), 'formula_pic_format': 'png'}
    else:
        # ⚠ v1 的 count 上限是 **5**（不是 10）。校内题带上难度带筛掉课内最浅的那档。
        #   （v2/ai **不收** difficulty_levels，只有 v1 收 —— 小克三组题面实测。）
        path, body = V1, {'course_id': cid, 'text': stem[:2000],
                          'count': min(5, n), 'formula_pic_format': 'png',
                          'difficulty_levels': (levels or LV_SCHOOL)[:5]}
    try:
        r = xkw.call(path, body=body)
    except Exception as e:
        return [], str(e)[:220]
    data = r.get('data') or []
    if not isinstance(data, list):
        data = data.get('questions') or data.get('list') or []
    got = len(data)
    # 难度只能在这一步滤（ai 不收 difficulty_levels）。
    # ⚠ **滤完不足就照实少给，不要放宽阈值凑数** —— 踩过：知识点那条腿明明筛了难度，
    #   成品里照样冒出「较易」题，就是从相似题这条腿漏进来的。
    data = [q for q in data if _diff_ok(q, tb)]
    stats.update(got=got, kept=len(data))
    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
        items = [_norm(q, pool) for q in data]
    if not items:
        return [], note + ('取回 %d 道但难度都不合格（%s），已全部滤掉'
                           % (got, '奥数只留较难/困难' if tb in OLYMPIAD_TB else '校内只留一般及以上')
                           if got else '')
    tail = '相似题 · %d 道' % len(items)
    if got > len(items):
        tail += '（原 %d 道，按难度滤掉 %d 道）' % (got, got - len(items))
    return items, note + tail


if __name__ == '__main__':
    import time
    for m in ('fast', 'more'):
        t = time.time()
        it, err = similar('鸡兔同笼，共有10个头，32条腿，问鸡和兔各有几只？', '四年级', 10, m)
        print(f'{m:<6} {time.time()-t:4.1f}s  {len(it)} 道  {err}')
        if it:
            print('   展示HTML含水印:', 'data-copyright' in it[0]['stem_html'])
            print('   导出文本:', it[0]['stem'][:70].replace('\n', ' '))
            print('   内嵌公式图:', it[0]['stem'].count('![](data:'))
