# -*- coding: utf-8 -*-
"""校园德育量化登记系统 · 桌面应用（本地运行，数据存飞书多维表格）"""
import json, os, hashlib, time, threading, webbrowser
import requests
from flask import Flask, request, jsonify, send_from_directory

BASE = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE, 'config.json')

def load_cfg():
    with open(CONFIG_PATH, encoding='utf-8') as f:
        return json.load(f)

def save_cfg(cfg):
    with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)

def sha(s):
    return hashlib.sha256((s or '').encode('utf-8')).hexdigest()

app = Flask(__name__)

@app.after_request
def no_cache_html(resp):
    if resp.mimetype == 'text/html':
        resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
        resp.headers['Pragma'] = 'no-cache'
    return resp

_token = {'v': None, 'exp': 0}
_tlock = threading.Lock()

def get_token():
    with _tlock:
        cfg = load_cfg()
        if _token['v'] and time.time() < _token['exp'] - 120:
            return _token['v']
        r = requests.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
                          json={'app_id': cfg['app_id'], 'app_secret': cfg['app_secret']}, timeout=15)
        d = r.json()
        if d.get('code') != 0 or not d.get('tenant_access_token'):
            raise RuntimeError('获取飞书令牌失败: ' + json.dumps(d, ensure_ascii=False))
        _token['v'] = d['tenant_access_token']
        _token['exp'] = time.time() + int(d.get('expire', 7200))
        return _token['v']

def bh():
    return {'Authorization': 'Bearer ' + get_token(), 'Content-Type': 'application/json'}

def fe_url(tid, extra=''):
    cfg = load_cfg()
    return f"https://open.feishu.cn/open-apis/bitable/v1/apps/{cfg['app_token']}/tables/{tid}/records" + extra

def list_records_raw(tid):
    out, pt = [], None
    while True:
        extra = '?page_size=500' + (('&page_token=' + pt) if pt else '')
        d = requests.get(fe_url(tid, extra), headers=bh(), timeout=25).json()
        if d.get('code') != 0:
            raise RuntimeError('读取记录失败: ' + json.dumps(d, ensure_ascii=False))
        data = d.get('data') or {}
        out += data.get('items') or []
        if data.get('has_more'):
            pt = data.get('page_token')
        else:
            break
    return out

# ---------- 操作日志 ----------
def write_log(action, target='', detail=''):
    try:
        cfg = load_cfg()
        tid = cfg.get('tid_logs')
        if not tid:
            return
        requests.post(fe_url(tid), headers=bh(), json={'fields': {
            '时间': int(time.time() * 1000),
            '动作': action,
            '对象': str(target or '')[:200],
            '详情': str(detail or '')[:500],
        }}, timeout=15)
    except Exception:
        pass

def record_brief(rid):
    """读取记录摘要用于日志展示，失败返回 rid"""
    try:
        cfg = load_cfg()
        d = requests.get(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(), timeout=15).json()
        f = (d.get('data') or {}).get('record', {}).get('fields') or {}
        return (str(f.get('年级') or '') + str(f.get('班级') or '') + '·' +
                str(f.get('事项细项') or '') + '(' + str(f.get('分值') or '') + '分)')
    except Exception:
        return rid

@app.route('/api/logs')
def get_logs():
    cfg = load_cfg()
    tid = cfg.get('tid_logs')
    out = []
    if tid:
        try:
            for item in list_records_raw(tid):
                f = item.get('fields') or {}
                t = f.get('时间')
                ts = t if isinstance(t, (int, float)) else 0
                out.append({
                    'time': time.strftime('%Y-%m-%d %H:%M', time.localtime(ts / 1000)) if ts else '',
                    '_ts': ts,
                    'action': f.get('动作') or '', 'target': f.get('对象') or '',
                    'detail': f.get('详情') or '',
                })
        except Exception:
            pass
    out.sort(key=lambda x: -x['_ts'])
    return jsonify(ok=True, logs=out[:200])

def norm_record(item):
    f = item.get('fields') or {}
    t = f.get('检查时间')
    ts = t if isinstance(t, (int, float)) else None
    tstr = time.strftime('%Y-%m-%d %H:%M', time.localtime(ts / 1000)) if ts else ''
    sc = f.get('分值')
    try:
        sc = float(sc)
    except (TypeError, ValueError):
        sc = 0
    return {
        'rid': item.get('record_id'),
        'time': tstr, '_ts': ts or 0,
        'grade': f.get('年级') or '', 'cls': f.get('班级') or '',
        'dept': f.get('部门') or '', 'item': f.get('事项细项') or '',
        'type': f.get('类型') or '', 'score': int(sc) if sc == int(sc) else sc,
        'desc': f.get('事实描述') or '', 'user': f.get('使用人') or '', 'student': f.get('学生') or '',
        'src': f.get('登记来源') or '',
        'status': f.get('状态') or '有效', 'cancel_reason': f.get('消分原因') or '',
    }

def norm_user(item):
    f = item.get('fields') or {}
    return {'rid': item.get('record_id'), 'name': f.get('姓名') or '',
            'grade': f.get('年级') or '', 'identity': f.get('身份') or '',
            'dept': rt(f.get('部门')) or '', 'grp': rt(f.get('组别')) or '',
            'cls': rt(f.get('班级')) or ''}

def get_appeals_raw():
    return list_records_raw(load_cfg()['tid_appeals'])

def rt(v):
    """飞书富文本/文本统一取字符串"""
    if isinstance(v, list) and v and isinstance(v[0], dict):
        return ''.join(str(x.get('text') or '') for x in v)
    return v

def upload_media(filename, data_bytes):
    """上传附件到飞书，返回 file_token（失败返回 None）"""
    try:
        cfg = load_cfg()
        url = 'https://open.feishu.cn/open-apis/drive/v1/medias/upload_all'
        files = {'file': (filename, data_bytes, 'image/jpeg')}
        data = {'file_name': filename, 'parent_type': 'bitable_image', 'size': str(len(data_bytes))}
        d = requests.post(url, headers={'Authorization': 'Bearer ' + get_token()}, data=data, files=files, timeout=120).json()
        return (d.get('data') or {}).get('file_token')
    except Exception:
        return None

def norm_appeal(item):
    f = item.get('fields') or {}
    t = f.get('提交时间')
    tstr = time.strftime('%Y-%m-%d %H:%M', time.localtime(t/1000)) if isinstance(t, (int, float)) else ''
    atts = [{'name': rt(x.get('name')) or '附件', 'url': x.get('url') or ''}
            for x in (f.get('附件') or []) if isinstance(x, dict)]
    return {'arid': item.get('record_id'), 'rid': rt(f.get('关联记录')) or '',
            'appellant': rt(f.get('申诉人')) or '', 'reason': rt(f.get('申诉理由')) or '',
            'time': tstr, 'status': rt(f.get('状态')) or '待处理', 'opinion': rt(f.get('处理意见')) or '',
            'attachments': atts}

_cred_cache = {'v': None, 'exp': 0}
_users_cache = {'v': None, 'exp': 0}

def get_users_cached():
    """用户表缓存 60s：登录查部门/教师名单用"""
    now = time.time()
    if _users_cache['v'] is None or now > _users_cache['exp']:
        try:
            _users_cache['v'] = [norm_user(i) for i in list_records_raw(load_cfg()['tid_users'])]
        except Exception:
            _users_cache['v'] = []
        _users_cache['exp'] = now + 60
    return _users_cache['v']

def get_creds():
    """凭据表缓存 60s：角色 -> 密码哈希"""
    now = time.time()
    if _cred_cache['v'] is None or now > _cred_cache['exp']:
        rows = {}
        try:
            tid = load_cfg().get('tid_creds')
            if tid:
                for it in list_records_raw(tid):
                    f = it.get('fields') or {}
                    rows[rt(f.get('角色')) or ''] = rt(f.get('密码哈希')) or ''
        except Exception:
            rows = {}
        _cred_cache['v'] = rows
        _cred_cache['exp'] = now + 60
    return _cred_cache['v']

ROLE_OF_LEVEL = {'reg': '学生会', 'admin': '管理员', 'bzr': '班主任'}

def check_pw(level, pw):
    role = ROLE_OF_LEVEL.get(level)
    h = get_creds().get(role) if role else None
    if not h:
        cfg = load_cfg()
        key = 'admin_pw_hash' if level == 'admin' else 'reg_pw_hash'
        return sha(pw) == cfg.get(key)
    if sha(pw) == h:
        return True
    # 管理员口令可作为登记级别通行（管理员全功能）
    if level != 'admin':
        ah = get_creds().get('管理员')
        if ah and sha(pw) == ah:
            return True
    return False

def need_auth(level):
    pw = request.headers.get('X-PW')
    if not check_pw(level, pw):
        return jsonify(ok=False, msg='口令验证失败，请重新输入' + level + '口令'), 401

# ---------- 页面 ----------
@app.route('/')
def index():
    return send_from_directory(os.path.join(BASE, 'templates'), 'index.html')

# ---------- 认证 ----------
@app.route('/api/login', methods=['POST'])
def login():
    b = request.get_json(force=True)
    level = b.get('level') or b.get('role'); pw = b.get('password')
    if level not in ('reg', 'admin', 'bzr'):
        write_log('登录失败', '参数错误', 'level=' + repr(level))
        return jsonify(ok=False, msg='参数错误'), 400
    if not check_pw(level, pw):
        return jsonify(ok=False, msg='口令不正确'), 401
    if level == 'reg':
        name = (b.get('name') or '').strip()
        if not name:
            return jsonify(ok=False, msg='请选择登记人'), 400
        dept = ''
        for u in get_users_cached():
            if u['name'] == name:
                dept = u.get('dept') or ''
                break
        return jsonify(ok=True, role='学生会', name=name, dept=dept)
    if level == 'bzr':
        name = (b.get('name') or '').strip()
        if not name:
            return jsonify(ok=False, msg='请选择姓名'), 400
        cls = ''
        found = False
        for u in get_users_cached():
            if u['name'] == name and u['identity'] == '老师':
                cls = u.get('cls') or ''
                found = True
                break
        if not found:
            return jsonify(ok=False, msg='该姓名不在教师名单中'), 401
        if not cls:
            return jsonify(ok=False, msg='该教师未绑定班级，请联系管理员在用户表填写'), 400
        return jsonify(ok=True, role='班主任', name=name, cls=cls)
    return jsonify(ok=True, role='管理员')

@app.route('/api/password', methods=['POST'])
def change_pw():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    level = b.get('level'); new = b.get('new_password')
    if level not in ('reg', 'admin', 'bzr') or not new or len(new) < 4:
        return jsonify(ok=False, msg='新口令至少 4 位'), 400
    cfg = load_cfg()
    role = ROLE_OF_LEVEL[level]
    h = sha(new)
    tid_c = cfg.get('tid_creds')
    target = None
    if tid_c:
        try:
            for it in list_records_raw(tid_c):
                f = it.get('fields') or {}
                if rt(f.get('角色')) == role:
                    target = it.get('record_id'); break
        except Exception:
            pass
    if target:
        d = requests.put(fe_url(tid_c) + '/' + target, headers=bh(),
                         json={'fields': {'密码哈希': h, '更新时间': int(time.time() * 1000)}}, timeout=20).json()
        if d.get('code') != 0:
            return jsonify(ok=False, msg='写入凭据表失败: ' + str(d.get('msg'))), 500
    else:
        cfg['admin_pw_hash' if level == 'admin' else 'reg_pw_hash'] = h
        save_cfg(cfg)
    _cred_cache['v'] = None
    write_log('修改口令', role + ' 口令', '口令已更新')
    return jsonify(ok=True)

# ---------- 用户表 ----------
@app.route('/api/users')
def get_users():
    users = [norm_user(i) for i in list_records_raw(load_cfg()['tid_users'])]
    return jsonify(ok=True, users=users)

@app.route('/api/users', methods=['POST'])
def add_user():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    name = (b.get('name') or '').strip()
    if not name:
        return jsonify(ok=False, msg='姓名必填'), 400
    cfg = load_cfg()
    fields = {'姓名': name, '年级': b.get('grade') or '', '身份': b.get('identity') or '', '部门': b.get('dept') or '', '组别': b.get('grp') or '', '班级': b.get('cls') or ''}
    d = requests.post(fe_url(cfg['tid_users']), headers=bh(), json={'fields': fields}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='写入失败: ' + str(d.get('msg'))), 500
    write_log('新增用户', name, str(b.get('grade') or '') + '·' + str(b.get('identity') or '') + '·' + str(b.get('dept') or ''))
    return jsonify(ok=True, rid=d['data']['record']['record_id'])

@app.route('/api/users/<rid>', methods=['DELETE'])
def del_user(rid):
    b = request.get_json(silent=True) or {}
    if not check_pw('admin', request.headers.get('X-PW') or b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    d = requests.delete(fe_url(cfg['tid_users']) + '/' + rid, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除用户', 'rid=' + rid)
        _users_cache['v'] = None
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

# ---------- 班级字典 ----------
@app.route('/api/classes')
def get_classes():
    cfg = load_cfg()
    tid = cfg.get('tid_classes')
    classes = {}
    if tid:
        try:
            for item in list_records_raw(tid):
                f = item.get('fields') or {}
                grade = f.get('年级'); cls = f.get('班级')
                if grade and cls:
                    classes.setdefault(grade, [])
                    if cls not in classes[grade]:
                        classes[grade].append(cls)
        except Exception:
            pass
    if not classes:
        classes = cfg.get('classes', {})
    for g in ('高一', '高二', '高三'):
        classes.setdefault(g, [])
    return jsonify(ok=True, classes=classes, source=('feishu' if classes else 'config'))

# ---------- 检查记录 ----------
@app.route('/api/records')
def get_records():
    items = list_records_raw(load_cfg()['tid_records'])
    recs = [norm_record(i) for i in items]
    recs.sort(key=lambda x: x['_ts'], reverse=True)
    return jsonify(ok=True, records=recs)

@app.route('/api/records', methods=['POST'])
def add_record():
    b = request.get_json(force=True)
    if not check_pw('reg', b.get('reg_password')):
        return jsonify(ok=False, msg='登记口令验证失败'), 401
    for k in ('grade', 'cls', 'dept', 'item', 'user'):
        if not (b.get(k) or '').strip():
            return jsonify(ok=False, msg='请完整填写：年级/班级/部门/事项细项/使用人'), 400
    try:
        score = float(b.get('score'))
    except (TypeError, ValueError):
        return jsonify(ok=False, msg='分值必须是数字'), 400
    cfg = load_cfg()
    if b.get('time_str'):
        tt = time.strptime(b['time_str'], '%Y-%m-%d %H:%M')
        ts = int(time.mktime(tt) * 1000)
    else:
        ts = int(time.time() * 1000)
    fields = {
        '检查时间': ts,
        '年级': b.get('grade'), '班级': (b.get('cls') or '').strip(),
        '部门': b.get('dept'), '事项细项': (b.get('item') or '').strip(),
        '类型': b.get('type') or '扣分', '分值': abs(score) if (b.get('type') != '加分') else abs(score),
        '事实描述': (b.get('desc') or '').strip(), '使用人': (b.get('user') or '').strip(),
        '登记来源': '桌面应用',
    }
    if b.get('type') == '加分':
        fields['分值'] = abs(score)
    else:
        fields['分值'] = -abs(score)
    d = requests.post(fe_url(cfg['tid_records']), headers=bh(), json={'fields': fields}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='写入失败: ' + str(d.get('msg'))), 500
    return jsonify(ok=True, rid=d['data']['record']['record_id'])

@app.route('/api/records/<rid>', methods=['PUT'])
def edit_record(rid):
    b = request.get_json(force=True) or {}
    if not check_pw('admin', request.headers.get('X-PW') or b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    fields = {}
    if b.get('score') is not None:
        try:
            v = float(b['score'])
            fields['分值'] = -abs(v) if b.get('type') == '扣分' else abs(v)
        except (TypeError, ValueError):
            return jsonify(ok=False, msg='分值必须是数字'), 400
    if b.get('type'): fields['类型'] = b['type']
    if b.get('desc') is not None: fields['事实描述'] = b['desc']
    if not fields:
        return jsonify(ok=False, msg='没有要修改的内容'), 400
    cfg = load_cfg()
    brief = record_brief(rid)
    d = requests.put(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(), json={'fields': fields}, timeout=20).json()
    if d.get('code') == 0:
        write_log('改分', brief, ', '.join(k + '=' + str(v) for k, v in fields.items()))
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

@app.route('/api/records/<rid>', methods=['DELETE'])
def del_record(rid):
    if not check_pw('admin', request.headers.get('X-PW')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    brief = record_brief(rid)
    d = requests.delete(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除记录', brief)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

# ---------- 细则字典 ----------
@app.route('/api/rules')
def get_rules():
    items = list_records_raw(load_cfg()['tid_rules'])
    rules = []
    for i in items:
        f = i.get('fields') or {}
        try:
            sc = float(f.get('默认分值'))
        except (TypeError, ValueError):
            sc = 0
        rules.append({'rid': i.get('record_id'), 'item': f.get('事项细项') or '',
                      'dept': f.get('归属部门') or '', 'type': f.get('类型') or '',
                      'score': int(sc) if sc == int(sc) else sc})
    rules.sort(key=lambda x: (x['dept'], x['type'], -abs(x['score']) if isinstance(x['score'], (int, float)) else 0))
    return jsonify(ok=True, rules=rules)

@app.route('/api/rules', methods=['POST'])
def add_rule():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    item = (b.get('item') or '').strip()
    if not item or not b.get('dept'):
        return jsonify(ok=False, msg='细项与部门必填'), 400
    cfg = load_cfg()
    fields = {'事项细项': item, '归属部门': b.get('dept'), '类型': b.get('type') or '扣分',
              '默认分值': int(b.get('score') or 1)}
    d = requests.post(fe_url(cfg['tid_rules']), headers=bh(), json={'fields': fields}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='写入失败: ' + str(d.get('msg'))), 500
    write_log('新增细则', str(b.get('dept') or '') + '·' + item, str(b.get('type') or '扣分') + ' ' + str(b.get('score') or 1) + '分')
    return jsonify(ok=True, rid=d['data']['record']['record_id'])

@app.route('/api/rules/<rid>', methods=['DELETE'])
def del_rule(rid):
    if not check_pw('admin', request.headers.get('X-PW')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    d = requests.delete(fe_url(cfg['tid_rules']) + '/' + rid, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除细则', 'rid=' + rid)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

# ---------- 申诉与消分 ----------
@app.route('/api/appeals')
def get_appeals():
    recs = {r['rid']: r for r in (norm_record(i) for i in list_records_raw(load_cfg()['tid_records']))}
    appeals = []
    for a in (norm_appeal(i) for i in get_appeals_raw()):
        a['record'] = recs.get(a['rid'])
        appeals.append(a)
    appeals.sort(key=lambda x: x['status'] != '待处理')
    return jsonify(ok=True, appeals=appeals)

@app.route('/api/appeals', methods=['POST'])
def add_appeal():
    b = request.get_json(force=True)
    rid = b.get('rid'); appellant = (b.get('appellant') or '').strip()
    reason = (b.get('reason') or '').strip()
    if not rid or not appellant or not reason:
        return jsonify(ok=False, msg='关联记录、申诉人、申诉理由必填'), 400
    cfg = load_cfg()
    fields = {'关联记录': rid, '申诉人': appellant, '申诉理由': reason,
              '提交时间': int(time.time() * 1000), '状态': '待处理'}
    atts = b.get('attachments') or []
    if isinstance(atts, list) and atts:
        import base64 as _b64
        toks = []
        for i, a in enumerate(atts[:3]):
            try:
                data = _b64.b64decode(a.split(',', 1)[-1])
                tk = upload_media('申诉附件%d.jpg' % (i + 1), data)
                if tk:
                    toks.append({'file_token': tk})
            except Exception:
                pass
        if toks:
            fields['附件'] = toks
    d = requests.post(fe_url(cfg['tid_appeals']), headers=bh(), json={'fields': fields}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='提交失败: ' + str(d.get('msg'))), 500
    write_log('发起申诉', appellant, '关联 ' + rid[:20] + (' · 附' + str(len(toks)) + '个附件' if toks else ''))
    requests.put(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(),
                 json={'fields': {'状态': '申诉中'}}, timeout=20)
    return jsonify(ok=True, arid=d['data']['record']['record_id'])

@app.route('/api/appeals/<arid>', methods=['POST'])
def handle_appeal(arid):
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    result = b.get('result')
    if result not in ('已维持', '已更正', '已消分', '已撤销'):
        return jsonify(ok=False, msg='处理结果参数错误'), 400
    opinion = (b.get('opinion') or '').strip()
    cfg = load_cfg()
    d = requests.get(fe_url(cfg['tid_appeals']) + '/' + arid, headers=bh(), timeout=20).json()
    rid = rt((d.get('data') or {}).get('record', {}).get('fields', {}).get('关联记录'))
    if not rid:
        return jsonify(ok=False, msg='申诉记录异常：未找到关联记录'), 500
    upd = {'状态': result, '处理意见': opinion}
    requests.put(fe_url(cfg['tid_appeals']) + '/' + arid, headers=bh(), json={'fields': upd}, timeout=20)
    rec_fields = {}
    if result == '已维持':
        rec_fields = {'状态': '有效'}
    elif result == '已更正':
        rec_fields = {'状态': '有效', '处理意见': opinion}
        try:
            ns = float(b.get('new_score'))
            rec_fields['分值'] = -abs(ns) if b.get('rec_type') != '加分' else abs(ns)
        except (TypeError, ValueError):
            pass
    elif result == '已消分':
        rec_fields = {'状态': '已消分', '消分原因': '申诉' + result + '：' + opinion}
    elif result == '已撤销':
        rec_fields = {'状态': '已撤销', '消分原因': '申诉' + result + '：' + opinion}
    requests.put(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(), json={'fields': rec_fields}, timeout=20)
    write_log('申诉处理', record_brief(rid), '结果：' + result + (('；' + opinion) if opinion else ''))
    return jsonify(ok=True)

@app.route('/api/records/<rid>/cancel', methods=['POST'])
def cancel_score(rid):
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    reason = (b.get('reason') or '管理员消分').strip()
    cfg = load_cfg()
    brief = record_brief(rid)
    d = requests.put(fe_url(cfg['tid_records']) + '/' + rid, headers=bh(),
                     json={'fields': {'状态': '已消分', '消分原因': reason}}, timeout=20).json()
    if d.get('code') == 0:
        write_log('消分', brief, '原因：' + reason)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

# ---------- 批量写入 ----------
@app.route('/api/records/batch', methods=['POST'])
def add_records_batch():
    b = request.get_json(force=True)
    if not check_pw('reg', b.get('reg_password')):
        return jsonify(ok=False, msg='登记口令验证失败'), 401
    recs = b.get('records') or []
    if not recs:
        return jsonify(ok=False, msg='没有可提交的记录'), 400
    cfg = load_cfg()
    fields_list = []
    for r in recs:
        try:
            score = float(r.get('score'))
        except (TypeError, ValueError):
            return jsonify(ok=False, msg='存在非数字分值'), 400
        for k in ('grade', 'cls', 'dept', 'item'):
            if not (r.get(k) or '').strip():
                return jsonify(ok=False, msg='存在未填写完整的行（缺 ' + k + '）'), 400
        v = -abs(score) if r.get('type') != '加分' else abs(score)
        fields_list.append({'fields': {
            '检查时间': int(time.time() * 1000),
            '年级': r['grade'], '班级': r['cls'], '部门': r['dept'],
            '事项细项': r['item'], '类型': r.get('type') or '扣分', '分值': v,
            '事实描述': (r.get('note') or '').strip(), '学生': (r.get('student') or '').strip(), '使用人': (r.get('user') or '').strip(),
            '登记来源': '桌面应用',
        }})
    ok_n = 0
    for i in range(0, len(fields_list), 500):
        chunk = fields_list[i:i + 500]
        d = requests.post(fe_url(cfg['tid_records']) + '/batch_create', headers=bh(), json={'records': chunk}, timeout=40).json()
        if d.get('code') != 0:
            return jsonify(ok=False, msg='批量写入失败: ' + str(d.get('msg')), done=ok_n), 500
        ok_n += len(chunk)
    return jsonify(ok=True, count=ok_n)

# ---------- 检查排班 ----------
DEFAULT_DUTY_RULES = {
    '纪检部': [{'slot':'早读','n':2},{'slot':'午休','n':2},{'slot':'晚自习','n':2}],
    '艺体部': [{'slot':'大课间','n':4},{'slot':'机动','n':1}],
    '宿管部': [{'slot':'早查','n':2},{'slot':'晚查','n':2}],
    '劳动部': [{'slot':'包干区小检','n':3},{'slot':'机动','n':1}],
    '学习部': [{'slot':'早读检查','n':2},{'slot':'作业收交','n':2}],
    '礼仪部': [{'slot':'执勤抽查','n':2},{'slot':'机动','n':2}],
}

@app.route('/api/duty_rules', methods=['GET', 'POST'])
def duty_rules():
    cfg = load_cfg()
    if request.method == 'GET':
        rules = cfg.get('duty_rules')
        if not rules:
            rules = DEFAULT_DUTY_RULES
            cfg['duty_rules'] = rules
            save_cfg(cfg)
        return jsonify(ok=True, rules=rules)
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    rules = b.get('rules')
    if not isinstance(rules, dict) or not rules:
        return jsonify(ok=False, msg='规则格式错误'), 400
    clean = {}
    for dept, slots in rules.items():
        if not isinstance(dept, str) or not dept.strip():
            continue
        clean[dept.strip()] = [{'slot': str(x.get('slot') or '').strip(), 'n': max(1, int(x.get('n') or 1))}
                               for x in (slots or []) if str(x.get('slot') or '').strip()]
    if not clean:
        return jsonify(ok=False, msg='至少保留一个部门的有效规则'), 400
    cfg['duty_rules'] = clean
    save_cfg(cfg)
    write_log('修改值勤规则', '排班配置', json.dumps(clean, ensure_ascii=False)[:400])
    return jsonify(ok=True)

@app.route('/api/duty_ai', methods=['POST'])
def duty_ai():
    """AI 排班：注入值勤规则+可用人员，返回结构化排班（可应用）或文本建议"""
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    llm = cfg.get('llm') or {}
    if not (llm.get('enabled') and llm.get('base_url') and llm.get('api_key') and llm.get('model')):
        return jsonify(ok=False, msg='AI 未接入：请在 config.json 中配置 "llm": {"enabled": true, "base_url": "...", "api_key": "...", "model": "..."} 后重启服务')
    instruction = (b.get('instruction') or '').strip()
    if not instruction:
        return jsonify(ok=False, msg='请输入调整指令'), 400
    import re as _re
    dept = (b.get('dept') or '').strip()
    grade = (b.get('grade') or '').strip()
    rules = cfg.get('duty_rules') or DEFAULT_DUTY_RULES
    pool = [u for u in get_users_cached()
            if (not dept or u.get('dept') == dept) and (not grade or u.get('grade') == grade)]
    if not pool:
        return jsonify(ok=False, msg='可用人员为空：请先在用户表为成员填写部门与组别'), 400
    pool_lines = '\n'.join('- ' + u['name'] + '（' + (u.get('dept') or '') + '/' + (u.get('grade') or '') + '/组' + (u.get('grp') or '?') + '）' for u in pool)
    rules_lines = '\n'.join(dp + '：' + ' + '.join(sl['slot'] + str(sl['n']) + '人' for sl in slots) for dp, slots in rules.items())
    system = (
        '你是学生会检查排班助手。请根据值勤规则、可用人员与用户指令，安排本周检查排班。\n'
        '【值勤规则（部门：每日时段与人数）】\n' + rules_lines +
        '\n【值勤日】A 组值勤周一/三/五，B 组值勤周二/四；高三分会免晚自习时段。\n'
        '【可用人员（姓名（部门/年级/组））】\n' + pool_lines +
        '\n【输出要求】先用一两句话说明安排思路；然后单独一行输出 JSON 数组（不要代码块包裹），'
        '格式：[{"weekday":"周一","slot":"时段名","name":"人员姓名"}]。'
        'weekday 限周一至周五；slot 必须来自该人员所属部门的规则时段；name 必须来自可用人员名单，不得编造。'
    )
    try:
        d = requests.post(llm['base_url'].rstrip('/') + '/chat/completions',
                          headers={'Authorization': 'Bearer ' + llm['api_key']},
                          json={'model': llm['model'], 'messages': [
                              {'role': 'system', 'content': system},
                              {'role': 'user', 'content': instruction}]}, timeout=90).json()
        text = ((d.get('choices') or [{}])[0].get('message') or {}).get('content') or ''
    except Exception as e:
        return jsonify(ok=False, msg='AI 调用失败: ' + str(e)[:200]), 500
    schedule = []
    m = _re.search(r'\[[\s\S]*?\]', text)
    if m:
        try:
            arr = json.loads(m.group(0))
            valid_names = {u['name']: u for u in pool}
            valid_slots = set()
            for dp, slots in rules.items():
                if dept and dp != dept:
                    continue
                for sl in slots:
                    valid_slots.add(sl['slot'])
            for x in arr:
                if not isinstance(x, dict):
                    continue
                nm = x.get('name')
                if nm in valid_names and x.get('weekday') in ('周一', '周二', '周三', '周四', '周五') and x.get('slot') in valid_slots:
                    schedule.append({'weekday': x['weekday'], 'slot': x['slot'],
                                     'name': nm, 'grp': valid_names[nm].get('grp') or ''})
        except Exception:
            schedule = []
    write_log('AI排班', llm['model'], instruction[:150] + ' → ' + str(len(schedule)) + ' 条')
    return jsonify(ok=True, reply=text, schedule=schedule)


@app.route('/api/duties')
def get_duties():
    base = request.args.get('date') or time.strftime('%Y-%m-%d')
    try:
        d0 = time.strptime(base, '%Y-%m-%d')
    except ValueError:
        return jsonify(ok=False, msg='日期格式错误'), 400
    import datetime as _dt
    dt = _dt.datetime.strptime(base, '%Y-%m-%d')
    monday = dt - _dt.timedelta(days=dt.weekday())
    start_ms = int(time.mktime(monday.timetuple()) * 1000)
    end_ms = start_ms + 7 * 86400 * 1000
    cfg = load_cfg()
    out = []
    tid = cfg.get('tid_duty')
    if tid:
        for it in list_records_raw(tid):
            f = it.get('fields') or {}
            raw = f.get('日期')
            try:
                ts = float(raw) if raw is not None and str(raw).strip() != '' else 0
            except (TypeError, ValueError):
                ts = 0
            if not (start_ms <= ts < end_ms):
                continue
            dstr = time.strftime('%Y-%m-%d', time.localtime(ts / 1000)) if ts else ''
            out.append({'did': it.get('record_id'), 'date': dstr,
                        'weekday': rt(f.get('星期')) or '', 'slot': rt(f.get('时段')) or '',
                        'name': rt(f.get('检查员')) or '', 'cls': rt(f.get('班级')) or '',
                        'dept': rt(f.get('部门')) or '', 'grp': rt(f.get('组别')) or ''})
    out.sort(key=lambda x: (x['date'], x['slot'], x['name']))
    return jsonify(ok=True, duties=out, monday=monday.strftime('%Y-%m-%d'))

@app.route('/api/duties/batch', methods=['POST'])
def add_duties_batch():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    recs = b.get('records') or []
    if not recs:
        return jsonify(ok=False, msg='没有可写入的安排'), 400
    cfg = load_cfg()
    tid = cfg.get('tid_duty')
    if not tid:
        return jsonify(ok=False, msg='未配置检查安排表'), 500
    fields_list = []
    for r in recs:
        fields_list.append({'fields': {
            '日期': int(r.get('date_ms') or 0), '星期': r.get('weekday') or '',
            '时段': r.get('slot') or '', '检查员': r.get('name') or '',
            '班级': r.get('cls') or '', '部门': r.get('dept') or '', '组别': r.get('grp') or '',
        }})
    # purge: 'week' 清本周全部 / 'week_dept' 清本周该部门 —— 防止重复生成堆积
    purge = b.get('purge')
    if purge in ('week', 'week_dept'):
        import datetime as _dt
        base = b.get('week_start') or time.strftime('%Y-%m-%d')
        dt0 = _dt.datetime.strptime(base, '%Y-%m-%d')
        monday = dt0 - _dt.timedelta(days=dt0.weekday())
        start_ms = int(time.mktime(monday.timetuple()) * 1000)
        end_ms = start_ms + 7 * 86400 * 1000
        stale = []
        for it in list_records_raw(tid):
            f = it.get('fields') or {}
            raw = f.get('日期')
            try:
                ts = float(raw) if raw is not None and str(raw).strip() != '' else 0
            except (TypeError, ValueError):
                ts = 0
            if start_ms <= ts < end_ms:
                if purge == 'week_dept' and rt(f.get('部门')) != (b.get('dept') or ''):
                    continue
                stale.append(it.get('record_id'))
        for i in range(0, len(stale), 500):
            chunk = stale[i:i + 500]
            requests.delete(fe_url(tid) + '/batch_delete', headers=bh(), json={'records': chunk}, timeout=40)
        print('purge 清除旧排班:', len(stale))
    ok_n = 0
    for i in range(0, len(fields_list), 500):
        chunk = fields_list[i:i + 500]
        d = requests.post(fe_url(tid) + '/batch_create', headers=bh(), json={'records': chunk}, timeout=40).json()
        if d.get('code') != 0:
            return jsonify(ok=False, msg='写入失败: ' + str(d.get('msg')), done=ok_n), 500
        ok_n += len(chunk)
    if recs:
        write_log('生成排班', '全校' if b.get('purge') == 'week' else (recs[0].get('dept') or ''), '共 ' + str(ok_n) + ' 条安排' + ('（清除旧排班 ' + str(len(stale)) + ' 条）' if purge else ''))
    return jsonify(ok=True, count=ok_n)

@app.route('/api/duties/<did>', methods=['DELETE'])
def del_duty(did):
    if not check_pw('admin', request.headers.get('X-PW')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    tid = cfg.get('tid_duty')
    if not tid:
        return jsonify(ok=False, msg='未配置检查安排表'), 500
    d = requests.delete(fe_url(tid) + '/' + did, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除排班', 'did=' + did)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

@app.route('/api/users/swap_ab', methods=['POST'])
def swap_ab():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    tid = cfg['tid_users']
    updates = []
    for u in (norm_user(i) for i in list_records_raw(tid)):
        grp = (u.get('grp') or '').strip().upper()
        if grp in ('A', 'B'):
            updates.append({'record_id': u['rid'], 'fields': {'组别': ('B' if grp == 'A' else 'A')}})
    ok_n = 0
    for i in range(0, len(updates), 500):
        chunk = updates[i:i + 500]
        d = requests.post(fe_url(tid) + '/batch_update', headers=bh(), json={'records': chunk}, timeout=40).json()
        if d.get('code') != 0:
            return jsonify(ok=False, msg='互换失败: ' + str(d.get('msg')), done=ok_n), 500
        ok_n += len(chunk)
    write_log('AB组互换', '用户表', '对调 ' + str(ok_n) + ' 人')
    _users_cache['v'] = None
    return jsonify(ok=True, count=ok_n)

# ---------- 公告与留言 ----------
@app.route('/api/notices')
def get_notices():
    cfg = load_cfg()
    out = []
    tid = cfg.get('tid_notices')
    if tid:
        try:
            for it in list_records_raw(tid):
                f = it.get('fields') or {}
                ts = f.get('发布时间') if isinstance(f.get('发布时间'), (int, float)) else 0
                pin = f.get('置顶')
                out.append({'nid': it.get('record_id'),
                            'title': rt(f.get('标题')) or '', 'content': rt(f.get('内容')) or '',
                            'by': rt(f.get('发布人')) or '', 'ts': ts,
                            'pin': bool(pin), 'time': time.strftime('%m-%d %H:%M', time.localtime(ts / 1000)) if ts else ''})
        except Exception:
            pass
    out.sort(key=lambda x: (-int(x['pin']), -x['ts']))
    return jsonify(ok=True, notices=out[:50])

@app.route('/api/notices', methods=['POST'])
def add_notice():
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    title = (b.get('title') or '').strip()
    content = (b.get('content') or '').strip()
    if not title or not content:
        return jsonify(ok=False, msg='标题与内容必填'), 400
    cfg = load_cfg()
    tid = cfg.get('tid_notices')
    if not tid:
        return jsonify(ok=False, msg='未配置公告表'), 500
    d = requests.post(fe_url(tid), headers=bh(), json={'fields': {
        '标题': title, '内容': content, '发布人': '管理员',
        '发布时间': int(time.time() * 1000), '置顶': bool(b.get('pin'))}}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='发布失败: ' + str(d.get('msg'))), 500
    write_log('发布公告', title[:30])
    return jsonify(ok=True)

@app.route('/api/notices/<nid>', methods=['DELETE'])
def del_notice(nid):
    if not check_pw('admin', request.headers.get('X-PW')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    tid = cfg.get('tid_notices')
    if not tid:
        return jsonify(ok=False, msg='未配置公告表'), 500
    d = requests.delete(fe_url(tid) + '/' + nid, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除公告', 'nid=' + nid)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

@app.route('/api/messages')
def get_messages():
    cfg = load_cfg()
    out = []
    tid = cfg.get('tid_messages')
    if tid:
        try:
            for it in list_records_raw(tid):
                f = it.get('fields') or {}
                ts = f.get('时间') if isinstance(f.get('时间'), (int, float)) else 0
                out.append({'mid': it.get('record_id'),
                            'time': time.strftime('%m-%d %H:%M', time.localtime(ts / 1000)) if ts else '',
                            '_ts': ts, 'sender': rt(f.get('发送人')) or '', 'identity': rt(f.get('身份')) or '',
                            'content': rt(f.get('内容')) or '', 'reply': rt(f.get('回复')) or ''})
        except Exception:
            pass
    out.sort(key=lambda x: -x['_ts'])
    return jsonify(ok=True, messages=out[:100])

@app.route('/api/messages', methods=['POST'])
def add_message():
    b = request.get_json(force=True)
    sender = (b.get('sender') or '').strip()
    identity = (b.get('identity') or '').strip()
    content = (b.get('content') or '').strip()
    if not sender or not content:
        return jsonify(ok=False, msg='发送人与内容必填'), 400
    if len(content) > 500:
        return jsonify(ok=False, msg='留言最长 500 字'), 400
    cfg = load_cfg()
    tid = cfg.get('tid_messages')
    if not tid:
        return jsonify(ok=False, msg='未配置留言表'), 500
    d = requests.post(fe_url(tid), headers=bh(), json={'fields': {
        '时间': int(time.time() * 1000), '发送人': sender, '身份': identity,
        '内容': content}}, timeout=20).json()
    if d.get('code') != 0:
        return jsonify(ok=False, msg='发送失败: ' + str(d.get('msg'))), 500
    write_log('留言', sender, content[:50])
    return jsonify(ok=True)

@app.route('/api/messages/<mid>', methods=['POST'])
def reply_message(mid):
    b = request.get_json(force=True)
    if not check_pw('admin', b.get('admin_password')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    reply = (b.get('reply') or '').strip()
    if not reply:
        return jsonify(ok=False, msg='回复内容必填'), 400
    cfg = load_cfg()
    tid = cfg.get('tid_messages')
    if not tid:
        return jsonify(ok=False, msg='未配置留言表'), 500
    d = requests.put(fe_url(tid) + '/' + mid, headers=bh(), json={'fields': {'回复': reply}}, timeout=20).json()
    if d.get('code') == 0:
        write_log('回复留言', 'mid=' + mid, reply[:50])
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

@app.route('/api/messages/<mid>', methods=['DELETE'])
def del_message(mid):
    if not check_pw('admin', request.headers.get('X-PW')):
        return jsonify(ok=False, msg='管理口令验证失败'), 401
    cfg = load_cfg()
    tid = cfg.get('tid_messages')
    if not tid:
        return jsonify(ok=False, msg='未配置留言表'), 500
    d = requests.delete(fe_url(tid) + '/' + mid, headers=bh(), timeout=20).json()
    if d.get('code') == 0:
        write_log('删除留言', 'mid=' + mid)
    return jsonify(ok=d.get('code') == 0, msg=d.get('msg'))

# ---------- 启动 ----------
def _warmup():
    """后台预热飞书凭证与常用缓存，避免首次登录慢"""
    def warm():
        try:
            get_token(); get_creds(); get_users_cached()
        except Exception:
            pass
    threading.Timer(1.0, warm).start()

_warmup()

def open_browser():
    time.sleep(1.2)
    webbrowser.open('http://127.0.0.1:8765')

if __name__ == '__main__':
    if not os.environ.get('DEYU_NO_BROWSER'):
        threading.Thread(target=open_browser, daemon=True).start()
    app.run(host=os.environ.get('DEYU_HOST', '127.0.0.1'), port=int(os.environ.get('DEYU_PORT', '8765')), debug=False)
