# app.py
import os
import time
import json
import random
import string
import smtplib
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formatdate
from functools import wraps

from flask import (
    Flask, render_template, request, jsonify, session,
    redirect, url_for, flash, abort, send_from_directory
)
from flask_caching import Cache
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
from captcha.image import ImageCaptcha
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import fitz  # PyMuPDF
from openai import OpenAI as DashScopeClient

# 引入数据库模型（添加 TeacherApplication）
from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction, QuestionBankItem, InviteCode, SystemNotification
from flask_socketio import SocketIO, emit, join_room as sio_join, leave_room as sio_leave

load_dotenv()

app = Flask(__name__)
app.secret_key = os.urandom(24)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')

# Session配置：设置永久session的有效期为10天
from datetime import timedelta
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=10)

# 内存存储（用于临时验证码）
captcha_store = {}
email_codes = {}

# 在线用户追踪（记录用户最后活跃时间）
online_users = {}  # {user_id: last_active_timestamp}

# 数据库配置
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)

# 缓存配置
app.config['CACHE_TYPE'] = 'SimpleCache'  # 使用简单的内存缓存，如果需要可以改成 RedisCache
app.config['CACHE_DEFAULT_TIMEOUT'] = 60  # 默认缓存 60 秒
cache = Cache(app)

# 自定义 Jinja2 过滤器：渲染图片标签
import re as _re
from markupsafe import Markup, escape as _escape

@app.template_filter('render_images')
def render_images_filter(text):
    if not text:
        return text
    escaped = _escape(text)
    result = _re.sub(
        r'\[img:(\/static\/uploads\/[^\]]+)\]',
        r'<img src="\1" class="max-w-full rounded-lg border border-slate-200 my-2" style="max-height:400px">',
        str(escaped)
    )
    return Markup(result)

# 阿里云号码认证服务客户端
acs_client = AcsClient(
    os.getenv('ALIBABA_CLOUD_ACCESS_KEY_ID', ''),
    os.getenv('ALIBABA_CLOUD_ACCESS_KEY_SECRET', ''),
    'cn-hangzhou'
)

# ========== 辅助函数 ==========

def get_online_count():
    """获取5分钟内活跃的用户数"""
    now = time.time()
    cutoff = now - 300  # 5分钟
    # 清理过期记录
    expired = [uid for uid, ts in online_users.items() if ts < cutoff]
    for uid in expired:
        del online_users[uid]
    return len(online_users)

@app.before_request
def track_online_user():
    user = session.get('user')
    if user:
        online_users[user['id']] = time.time()

def generate_captcha_id():
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))

def generate_code(length=6):
    return ''.join(random.choices(string.digits, k=length))

def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if 'user' not in session:
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated

# ========== 考试加密工具 ==========
import base64
import hashlib

EXAM_ENCRYPT_KEY = os.getenv('EXAM_ENCRYPT_KEY', 'default_exam_secret_key_2026')

def _derive_key(key_str):
    return hashlib.sha256(key_str.encode()).digest()

def encrypt_questions(questions_json):
    """使用 XOR + base64 加密试卷内容"""
    key = _derive_key(EXAM_ENCRYPT_KEY)
    data = questions_json.encode('utf-8')
    encrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])
    return base64.b64encode(encrypted).decode('utf-8')

def decrypt_questions(encrypted_str):
    """解密试卷内容"""
    key = _derive_key(EXAM_ENCRYPT_KEY)
    data = base64.b64decode(encrypted_str.encode('utf-8'))
    decrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])
    return decrypted.decode('utf-8')

def get_exam_questions(exam):
    """统一获取考试题目（自动处理加密）"""
    if exam.is_encrypted and exam.encrypted_questions:
        return json.loads(decrypt_questions(exam.encrypted_questions))
    return exam.get_questions()

def teacher_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        user = session.get('user')
        if not user:
            return redirect(url_for('login'))
        if user.get('role') not in ('teacher', 'admin'):
            return redirect(url_for('exam_list'))
        return f(*args, **kwargs)
    return decorated

def send_email(to_email, subject, html_content):
    smtp_host = os.getenv('SMTP_HOST', 'smtp.163.com')
    smtp_port = int(os.getenv('SMTP_PORT', 465))
    smtp_user = os.getenv('SMTP_USER', '')
    smtp_pass = os.getenv('SMTP_PASS', '')

    print(f'[邮件发送] 准备发送邮件到: {to_email}')
    print(f'[邮件发送] SMTP服务器: {smtp_host}:{smtp_port}')
    print(f'[邮件发送] 发件人: {smtp_user}')

    if not smtp_user or not smtp_pass:
        raise ValueError('SMTP_USER 或 SMTP_PASS 未配置')

    msg = MIMEMultipart('alternative')
    msg['From'] = smtp_user
    msg['To'] = to_email
    msg['Subject'] = subject
    msg['Date'] = formatdate(localtime=True)
    msg.attach(MIMEText(html_content, 'html', 'utf-8'))

    # 尝试SSL连接（端口465）
    try:
        print(f'[邮件发送] 尝试SSL连接 (端口{smtp_port})...')
        server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
        server.set_debuglevel(0)
        server.ehlo()
        print(f'[邮件发送] SSL连接成功，正在登录...')
        server.login(smtp_user, smtp_pass)
        print(f'[邮件发送] 登录成功，正在发送邮件...')
        server.sendmail(smtp_user, [to_email], msg.as_string())
        server.quit()
        print(f'[邮件发送] 邮件发送成功到 {to_email}')
        return
    except Exception as e:
        print(f'[邮件发送] SSL方式失败: {e}')

    # 尝试STARTTLS连接（端口587）
    try:
        print(f'[邮件发送] 尝试STARTTLS连接 (端口587)...')
        server = smtplib.SMTP(smtp_host, 587, timeout=30)
        server.set_debuglevel(0)
        server.ehlo()
        server.starttls()
        server.ehlo()
        print(f'[邮件发送] STARTTLS连接成功，正在登录...')
        server.login(smtp_user, smtp_pass)
        print(f'[邮件发送] 登录成功，正在发送邮件...')
        server.sendmail(smtp_user, [to_email], msg.as_string())
        server.quit()
        print(f'[邮件发送] 邮件发送成功到 {to_email}')
        return
    except Exception as e:
        print(f'[邮件发送] STARTTLS(587)方式失败: {e}')

    # 尝试STARTTLS连接（端口25）
    try:
        print(f'[邮件发送] 尝试STARTTLS连接 (端口25)...')
        server = smtplib.SMTP(smtp_host, 25, timeout=30)
        server.set_debuglevel(0)
        server.ehlo()
        server.starttls()
        server.ehlo()
        print(f'[邮件发送] STARTTLS连接成功，正在登录...')
        server.login(smtp_user, smtp_pass)
        print(f'[邮件发送] 登录成功，正在发送邮件...')
        server.sendmail(smtp_user, [to_email], msg.as_string())
        server.quit()
        print(f'[邮件发送] 邮件发送成功到 {to_email}')
        return
    except Exception as e:
        print(f'[邮件发送] STARTTLS(25)方式失败: {e}')

    raise Exception(f'所有邮件发送方式均失败，请检查SMTP配置和网络连接')

def get_current_user():
    return session.get('user')

def require_login():
    user = get_current_user()
    if not user:
        abort(401)
    return user

def require_admin_or_teacher():
    user = require_login()
    if user['role'] not in ['admin', 'teacher']:
        abort(403)
    return user
def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        user = get_current_user()
        if not user or user['role'] != 'admin':
            abort(403)
        return f(*args, **kwargs)
    return decorated

# 积分等级相关
LEVEL_TITLES = {1:'新手上路',2:'初出茅庐',3:'小有名气',4:'渐入佳境',5:'驾轻就熟',6:'炉火纯青',7:'学富五车',8:'出类拔萃',9:'登峰造极',10:'一代宗师'}

def calc_level(points):
    if points >= 5000: return 10
    if points >= 3000: return 9
    if points >= 2000: return 8
    if points >= 1200: return 7
    if points >= 800: return 6
    if points >= 500: return 5
    if points >= 300: return 4
    if points >= 150: return 3
    if points >= 50: return 2
    return 1

def add_notification(user_id, ntype, content, from_user='', post_id=0):
    notif = Notification(
        user_id=user_id,
        type=ntype,
        content=content,
        from_user=from_user,
        post_id=post_id
    )
    db.session.add(notif)
    db.session.commit()

def send_private_message(from_user_id, to_user_id, content, msg_type='system'):
    """发送私聊消息（查找或创建私聊室）"""
    # 查找已有私聊室
    my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=from_user_id).subquery()
    target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=to_user_id).subquery()
    room = ChatRoom.query.filter(
        ChatRoom.type == 'private',
        ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)),
        ChatRoom.id.in_(db.session.query(target_rooms.c.room_id))
    ).first()
    if not room:
        room = ChatRoom(type='private', creator_id=from_user_id)
        db.session.add(room)
        db.session.flush()
        db.session.add(ChatRoomMember(room_id=room.id, user_id=from_user_id, role='member'))
        db.session.add(ChatRoomMember(room_id=room.id, user_id=to_user_id, role='member'))
    msg = Message(room_id=room.id, sender_id=from_user_id, type=msg_type, content=content)
    db.session.add(msg)
    db.session.flush()
    sender = User.query.get(from_user_id)
    socketio.emit('new_message', {
        'id': msg.id, 'room_id': room.id, 'sender_id': from_user_id,
        'sender_name': sender.name if sender else '系统',
        'sender_avatar': (sender.avatar or '') if sender else '',
        'type': msg_type, 'content': content,
        'file_url': None, 'file_name': None, 'reply_to': None,
        'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
    }, room=f'room_{room.id}')

# ========== 杯赛讨论区发帖权限检查 ==========
def can_post_in_contest(user, contest):
    """检查用户是否可以在指定杯赛的讨论区发帖"""
    if user['role'] in ['admin', 'teacher']:
        return True
    # 杯赛负责人或老师
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest.id).first()
    if membership and membership.role in ['owner', 'teacher']:
        return True
    # 报名且至少参与一次考试
    registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest.id).first()
    if not registration:
        return False
    # 获取该杯赛下的所有考试 ID
    exam_ids = [exam.id for exam in contest.exams]
    if not exam_ids:
        return False
    # 查询用户在这些考试中的提交记录
    submission_count = Submission.query.filter(
        Submission.user_id == user['id'],
        Submission.exam_id.in_(exam_ids)
    ).count()
    return submission_count >= 1

def can_grade_exam(user, exam):
    """检查用户是否可以批改指定考试"""
    if not user:
        return False
    if user.get('role') in ('teacher', 'admin'):
        return True
    # 杯赛老师可以批改所属杯赛的考试
    if exam.contest_id:
        membership = ContestMembership.query.filter_by(
            user_id=user['id'], contest_id=exam.contest_id).first()
        if membership and membership.role in ('owner', 'teacher'):
            return True
    return False

# ========== Jinja2 过滤器 ==========
@app.template_filter('fromjson')
def fromjson_filter(s):
    """将 JSON 字符串解析为 Python 对象"""
    try:
        return json.loads(s) if s else []
    except (json.JSONDecodeError, TypeError):
        return []

# ========== 上下文处理器 ==========
def get_display_name(user_id, base_name):
    """获取用户显示名称，如果是杯赛负责人或老师则附加杯赛信息"""
    memberships = ContestMembership.query.filter_by(user_id=user_id).all()
    if not memberships:
        return base_name
    role_names = []
    for m in memberships:
        contest = Contest.query.get(m.contest_id)
        if contest and contest.status != 'abolished':
            if m.role == 'owner':
                role_names.append(f'{contest.name}负责人')
            elif m.role == 'teacher':
                role_names.append(f'{contest.name}老师')
    if not role_names:
        return base_name
    return f"{base_name}（{'、'.join(role_names)}）"

@app.context_processor
def inject_user():
    return {'user': get_current_user()}

@app.context_processor
def inject_display_name():
    def get_user_display_name():
        user = session.get('user')
        if not user:
            return ''
        return get_display_name(user['id'], user['name'])
    return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name)

# ========== 页面路由 ==========
@app.route('/')
def home():
    online_count = get_online_count()
    contest_count = Contest.query.count()
    return render_template('home.html', online_count=online_count, contest_count=contest_count)

@app.route('/login', methods=['GET'])
def login():
    return render_template('login.html')

@app.route('/register', methods=['GET'])
def register():
    return render_template('register.html')

@app.route('/logout')
def logout():
    session.pop('user', None)
    return redirect(url_for('login'))

@app.route('/contests')
def contest_list():
    return render_template('contest_list.html')

@app.route('/contests/<int:contest_id>')
def contest_detail(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return redirect(url_for('contest_list'))
    user = get_current_user()
    # 未发布的杯赛只有负责人和管理员可见
    if not contest.visible:
        if not user:
            return redirect(url_for('contest_list'))
        if user.get('role') != 'admin':
            membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
            if not membership:
                return redirect(url_for('contest_list'))
    registered = False
    can_post = False
    is_member = False
    is_owner = False
    if user:
        # 检查是否报名
        registered = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() is not None
        # 检查是否有发帖权限
        can_post = can_post_in_contest(user, contest)
        # 检查是否为成员（负责人或老师）
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
        is_member = membership is not None
        is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    return render_template('contest_detail.html', contest=contest, registered=registered, can_post=can_post, is_member=is_member, is_owner=is_owner)

@app.route('/contests/<int:contest_id>/question-bank')
@login_required
def contest_question_bank(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return redirect(url_for('contest_list'))
    if contest.status == 'abolished':
        return redirect(url_for('contest_detail', contest_id=contest_id))
    user = get_current_user()
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    if not membership and user.get('role') != 'admin':
        return redirect(url_for('contest_detail', contest_id=contest_id))
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    return render_template('contest_question_bank.html', contest=contest, is_owner=is_owner)

@app.route('/contests/<int:contest_id>/edit')
@login_required
def contest_edit(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return redirect(url_for('contest_list'))
    user = get_current_user()
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return redirect(url_for('contest_detail', contest_id=contest_id))
    return render_template('contest_edit.html', contest=contest)

@app.route('/api/contests/<int:contest_id>/edit', methods=['PUT'])
@login_required
def api_contest_edit(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    user = get_current_user()
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '没有权限'}), 403
    data = request.get_json(force=True, silent=True)
    if data.get('description') is not None:
        contest.description = data['description']
    if data.get('organizer') is not None:
        contest.organizer = data['organizer']
    if data.get('start_date') is not None:
        contest.start_date = data['start_date']
    if data.get('end_date') is not None:
        contest.end_date = data['end_date']
    if data.get('status') in ('upcoming', 'registering', 'ongoing', 'ended'):
        contest.status = data['status']
    db.session.commit()
    return jsonify({'success': True})

ALLOWED_PAPER_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp'}

@app.route('/api/contests/<int:contest_id>/past-papers', methods=['POST'])
@login_required
def api_contest_upload_paper(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    user = get_current_user()
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '没有权限'}), 403
    if 'file' not in request.files:
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
    if ext not in ALLOWED_PAPER_EXTENSIONS:
        return jsonify({'success': False, 'message': '仅支持 PDF 和图片文件'}), 400
    file.seek(0, 2)
    if file.tell() > 10 * 1024 * 1024:
        return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400
    file.seek(0)
    year = request.form.get('year', '')
    title = request.form.get('title', '')
    if not year or not title:
        return jsonify({'success': False, 'message': '年份和标题不能为空'}), 400
    filename = f"paper_{contest_id}_{int(time.time())}_{secure_filename(file.filename)}"
    upload_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
    os.makedirs(upload_dir, exist_ok=True)
    file.save(os.path.join(upload_dir, filename))
    url = f'/static/uploads/{filename}'
    papers = contest.get_past_papers()
    papers.append({'year': year, 'title': title, 'file': url})
    contest.set_past_papers(papers)
    db.session.commit()
    return jsonify({'success': True, 'papers': contest.get_past_papers()})

@app.route('/api/contests/<int:contest_id>/past-papers/<int:index>', methods=['DELETE'])
@login_required
def api_contest_delete_paper(contest_id, index):
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    user = get_current_user()
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '没有权限'}), 403
    papers = contest.get_past_papers()
    if index < 0 or index >= len(papers):
        return jsonify({'success': False, 'message': '索引无效'}), 400
    papers.pop(index)
    contest.set_past_papers(papers)
    db.session.commit()
    return jsonify({'success': True, 'papers': contest.get_past_papers()})

@app.route('/exams')
@login_required
def exam_list():
    user = session.get('user')

    search_query = request.args.get('q', '').strip()
    subject_filter = request.args.get('subject', '').strip()

    query = Exam.query
    if subject_filter:
        query = query.filter_by(subject=subject_filter)
    if search_query:
        query = query.filter(
            (Exam.title.contains(search_query)) | (Exam.subject.contains(search_query))
        )
    all_exams = query.all()

    all_subjects = db.session.query(Exam.subject).distinct().all()
    all_subjects = [s[0] for s in all_subjects if s[0]]

    user_submissions = {}
    for sub in Submission.query.filter_by(user_id=user.get('id')).all():
        user_submissions[sub.exam_id] = {
            'id': sub.id,
            'graded': sub.graded,
            'score': sub.score
        }

    return render_template(
        'exam_list.html',
        exams=all_exams,
        user_submissions=user_submissions,
        search_query=search_query,
        subject_filter=subject_filter,
        all_subjects=all_subjects
    )

@app.route('/exams/create')
@login_required
def exam_create():
    user = session.get('user')
    # 允许 teacher/admin 创建任意考试
    if user.get('role') in ('teacher', 'admin'):
        return render_template('exam_create.html')
    # 允许杯赛负责人创建其杯赛的考试
    contest_id = request.args.get('contest_id', type=int)
    if contest_id:
        membership = ContestMembership.query.filter_by(
            user_id=user['id'], contest_id=contest_id, role='owner').first()
        if membership:
            return render_template('exam_create.html')
    # 检查用户是否是任何杯赛的负责人
    any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first()
    if any_ownership:
        return render_template('exam_create.html')
    return redirect(url_for('exam_list'))

@app.route('/exams/<int:exam_id>')
@login_required
def exam_detail(exam_id):
    exam = Exam.query.get(exam_id)
    if not exam:
        return redirect(url_for('exam_list'))
    user = session.get('user')
    # 密码验证
    if exam.access_password:
        verified_key = f'exam_verified_{exam_id}'
        if not session.get(verified_key):
            return render_template('exam_detail.html', exam=exam, need_password=True,
                                   questions=[], existing_submission=None, draft=None,
                                   schedule_status='available')
    existing = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
    draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
    questions = get_exam_questions(exam)
    # 检查预定时间
    now = datetime.utcnow()
    schedule_status = 'available'  # available, not_started, ended
    if exam.scheduled_start and now < exam.scheduled_start:
        schedule_status = 'not_started'
    if exam.scheduled_end and now > exam.scheduled_end:
        schedule_status = 'ended'
    return render_template('exam_detail.html', exam=exam, questions=questions,
                           existing_submission=existing, draft=draft,
                           schedule_status=schedule_status, need_password=False)

@app.route('/exams/<int:exam_id>/result')
@login_required
def exam_result(exam_id):
    exam = Exam.query.get(exam_id)
    if not exam:
        return redirect(url_for('exam_list'))
    user = session.get('user')
    submission = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
    if not submission:
        return redirect(url_for('exam_detail', exam_id=exam_id))
    # 检查成绩公布时间
    score_hidden = False
    if exam.score_release_time and datetime.utcnow() < exam.score_release_time:
        if user.get('role') != 'teacher' and user.get('role') != 'admin':
            score_hidden = True
    questions = get_exam_questions(exam)
    answers = submission.get_answers()
    question_scores = submission.get_question_scores()
    return render_template('exam_result.html', exam=exam, submission=submission,
                           questions=questions, answers=answers, question_scores=question_scores,
                           score_hidden=score_hidden)

@app.route('/exams/<int:exam_id>/submissions')
@login_required
def exam_submissions(exam_id):
    exam = Exam.query.get(exam_id)
    if not exam:
        return redirect(url_for('exam_list'))
    user = session.get('user')
    if not can_grade_exam(user, exam):
        return redirect(url_for('exam_list'))
    subs = Submission.query.filter_by(exam_id=exam_id).all()
    stats = {}
    if subs:
        graded_subs = [s for s in subs if s.graded]
        scores = [s.score for s in graded_subs] if graded_subs else []
        stats = {
            'total': len(subs),
            'graded': len(graded_subs),
            'ungraded': len(subs) - len(graded_subs),
            'avg_score': round(sum(scores) / len(scores), 1) if scores else 0,
            'max_score': max(scores) if scores else 0,
            'min_score': min(scores) if scores else 0,
        }
    next_ungraded = None
    for s in subs:
        if not s.graded:
            next_ungraded = s.id
            break
    return render_template('exam_submissions.html', exam=exam, submissions=subs, stats=stats, next_ungraded=next_ungraded)

@app.route('/exams/<int:exam_id>/grade/<int:sub_id>')
@login_required
def exam_grade(exam_id, sub_id):
    exam = Exam.query.get(exam_id)
    sub = Submission.query.get(sub_id)
    if not exam or not sub:
        return redirect(url_for('exam_list'))
    user = session.get('user')
    if not can_grade_exam(user, exam):
        return redirect(url_for('exam_list'))
    next_ungraded = None
    next_sub = Submission.query.filter_by(exam_id=exam_id, graded=False).filter(Submission.id != sub_id).first()
    if next_sub:
        next_ungraded = next_sub.id
    questions = get_exam_questions(exam)
    answers = sub.get_answers()
    question_scores = sub.get_question_scores()
    return render_template('exam_grade.html', exam=exam, submission=sub,
                           questions=questions, answers=answers, question_scores=question_scores,
                           next_ungraded=next_ungraded)

@app.route('/exams/<int:exam_id>/print')
@login_required
def exam_print(exam_id):
    exam = Exam.query.get(exam_id)
    if not exam:
        return redirect(url_for('exam_list'))
    questions = get_exam_questions(exam)
    return render_template('exam_print.html', exam=exam, questions=questions)

@app.route('/forum')
def forum():
    return render_template('forum.html')

@app.route('/profile')
@login_required
def profile():
    user_data = session.get('user')
    user = User.query.get(user_data['id'])
    post_count = Post.query.filter_by(author_id=user.id).count()
    reply_count = Reply.query.filter_by(author_id=user.id).count()
    likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0
    points = post_count * 10 + reply_count * 3 + likes_received * 2
    level = calc_level(points)
    return render_template('profile.html', profile_user=user, post_count=post_count,
                           reply_count=reply_count, likes_received=likes_received,
                           points=points, level=level)

# ========== 好友系统 API ==========

@app.route('/api/user/friends')
@login_required
def api_user_friends():
    user_id = session['user']['id']
    friendships = Friend.query.filter(
        ((Friend.user_id == user_id) | (Friend.friend_id == user_id)) &
        (Friend.status == 'accepted')
    ).all()
    friends = []
    for f in friendships:
        friend_user = User.query.get(f.friend_id if f.user_id == user_id else f.user_id)
        if friend_user:
            friends.append({
                'id': friend_user.id,
                'name': friend_user.name,
                'avatar': friend_user.avatar or '',
                'created_at': f.created_at.strftime('%Y-%m-%d')
            })
    return jsonify({'success': True, 'friends': friends})

@app.route('/api/friend/add', methods=['POST'])
@login_required
def api_add_friend():
    user_id = session['user']['id']
    data = request.get_json(force=True, silent=True)
    friend_id = data.get('friend_id')
    if not friend_id or friend_id == user_id:
        return jsonify({'success': False, 'message': '无效的好友ID'}), 400

    # 检查是否已经是好友或有待处理的请求
    existing = Friend.query.filter(
        ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) |
        ((Friend.user_id == friend_id) & (Friend.friend_id == user_id))
    ).first()

    if existing:
        # 如果已经是好友，不允许重复申请
        if existing.status == 'accepted':
            return jsonify({'success': False, 'message': '已经是好友'}), 400

        # 如果有待处理的请求，不允许重复发送
        if existing.status == 'pending':
            return jsonify({'success': False, 'message': '已发送好友请求，请等待对方处理'}), 400

        # 如果之前被拒绝，允许重新发送（更新状态为pending）
        if existing.status == 'rejected':
            # 确保是当前用户发起的请求才能更新
            if existing.user_id == user_id:
                existing.status = 'pending'
                existing.created_at = datetime.utcnow()
                db.session.flush()

                # 重新发送通知给对方
                sender_name = session['user'].get('name', '未知用户')
                notif = Notification(user_id=friend_id, type='friend_request',
                                   content=f'{sender_name} 请求添加你为好友',
                                   from_user=sender_name, post_id=existing.id)
                db.session.add(notif)
                db.session.commit()
                return jsonify({'success': True, 'message': '好友请求已重新发送'})
            else:
                # 如果是对方之前发起的请求被拒绝，当前用户可以创建新的请求
                pass

    # 创建新的好友请求
    friend_req = Friend(user_id=user_id, friend_id=friend_id, status='pending')
    db.session.add(friend_req)
    db.session.flush()
    # 发送通知给对方
    sender_name = session['user'].get('name', '未知用户')
    notif = Notification(user_id=friend_id, type='friend_request', content=f'{sender_name} 请求添加你为好友', from_user=sender_name, post_id=friend_req.id)
    db.session.add(notif)
    db.session.commit()
    return jsonify({'success': True, 'message': '好友请求已发送'})

@app.route('/api/friend/accept/<int:request_id>', methods=['POST'])
@login_required
def api_accept_friend(request_id):
    user_id = session['user']['id']
    req = Friend.query.get(request_id)
    if not req or req.friend_id != user_id or req.status != 'pending':
        return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404
    req.status = 'accepted'
    # 自动创建私聊室，让好友立即出现在聊天列表
    friend_user_id = req.user_id
    my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=user_id).subquery()
    target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=friend_user_id).subquery()
    existing_room = ChatRoom.query.filter(
        ChatRoom.type == 'private',
        ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)),
        ChatRoom.id.in_(db.session.query(target_rooms.c.room_id))
    ).first()
    if not existing_room:
        room = ChatRoom(type='private', creator_id=user_id)
        db.session.add(room)
        db.session.flush()
        db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='member'))
        db.session.add(ChatRoomMember(room_id=room.id, user_id=friend_user_id, role='member'))
    db.session.commit()
    return jsonify({'success': True, 'message': '好友已添加'})

@app.route('/api/friend/reject/<int:request_id>', methods=['POST'])
@login_required
def api_reject_friend(request_id):
    user_id = session['user']['id']
    req = Friend.query.get(request_id)
    if not req or req.friend_id != user_id or req.status != 'pending':
        return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404
    db.session.delete(req)
    db.session.commit()
    return jsonify({'success': True, 'message': '已拒绝'})

@app.route('/api/friend/requests')
@login_required
def api_friend_requests():
    user_id = session['user']['id']
    pending = Friend.query.filter_by(friend_id=user_id, status='pending').all()
    result = []
    for f in pending:
        u = User.query.get(f.user_id)
        if u:
            result.append({'id': f.id, 'user_id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'created_at': f.created_at.strftime('%Y-%m-%d')})
    return jsonify({'success': True, 'requests': result})

@app.route('/api/users/search')
@login_required
def api_users_search():
    user_id = session['user']['id']
    q = request.args.get('q', '').strip()
    if not q:
        return jsonify({'success': True, 'users': []})
    users = User.query.filter(User.name.contains(q), User.id != user_id).limit(20).all()
    result = []
    for u in users:
        existing = Friend.query.filter(
            ((Friend.user_id == user_id) & (Friend.friend_id == u.id)) |
            ((Friend.user_id == u.id) & (Friend.friend_id == user_id))
        ).first()
        status = existing.status if existing else None
        result.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'friend_status': status})
    return jsonify({'success': True, 'users': result})

# ========== 我的帖子 API ==========

@app.route('/api/user/posts')
@login_required
def api_user_posts():
    user_id = session['user']['id']
    posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all()
    data = [{
        'id': p.id,
        'title': p.title,
        'content': p.content[:100] + '...' if len(p.content) > 100 else p.content,
        'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
        'replies': p.replies_count,
        'likes': p.likes
    } for p in posts]
    return jsonify({'success': True, 'posts': data})

# ========== 试卷收藏 API ==========

@app.route('/api/user/exam-bookmarks')
@login_required
def api_user_exam_bookmarks():
    user_id = session['user']['id']
    bookmarks = ExamBookmark.query.filter_by(user_id=user_id).order_by(ExamBookmark.created_at.desc()).all()
    data = []
    for bm in bookmarks:
        exam = bm.exam
        if exam:
            data.append({
                'id': exam.id,
                'title': exam.title,
                'subject': exam.subject,
                'total_score': exam.total_score,
                'duration': exam.duration,
                'status': exam.status,
                'bookmarked_at': bm.created_at.strftime('%Y-%m-%d %H:%M')
            })
    return jsonify({'success': True, 'bookmarks': data})

@app.route('/api/user/exam-history')
@login_required
def api_user_exam_history():
    user = session['user']
    subs = Submission.query.filter_by(user_id=user['id']).order_by(Submission.submitted_at.desc()).all()
    now = datetime.utcnow()
    result = []
    for s in subs:
        exam = s.exam
        if not exam:
            continue
        contest_name = ''
        if exam.contest_id:
            contest = Contest.query.get(exam.contest_id)
            if contest:
                contest_name = contest.name
        # 成绩公布时间控制
        score_visible = True
        if exam.score_release_time and now < exam.score_release_time and user.get('role') not in ('teacher', 'admin'):
            score_visible = False
        result.append({
            'exam_id': exam.id,
            'title': exam.title,
            'subject': exam.subject,
            'total_score': exam.total_score,
            'contest_name': contest_name,
            'submitted_at': s.submitted_at.strftime('%Y-%m-%d %H:%M') if s.submitted_at else '',
            'graded': s.graded,
            'score': s.score if score_visible else None
        })
    return jsonify({'success': True, 'history': result})

@app.route('/api/exams/<int:exam_id>/bookmark', methods=['POST', 'DELETE'])
@login_required
def api_toggle_exam_bookmark(exam_id):
    user_id = session['user']['id']
    if request.method == 'POST':
        existing = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first()
        if existing:
            return jsonify({'success': False, 'message': '已收藏'}), 400
        bm = ExamBookmark(user_id=user_id, exam_id=exam_id)
        db.session.add(bm)
        db.session.commit()
        return jsonify({'success': True, 'message': '收藏成功'})
    else:
        bm = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first()
        if bm:
            db.session.delete(bm)
            db.session.commit()
        return jsonify({'success': True, 'message': '已取消收藏'})

@app.route('/api/user/avatar', methods=['POST'])
@login_required
def api_upload_avatar():
    user_data = session.get('user')
    if 'file' not in request.files:
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    if not allowed_file(file.filename):
        return jsonify({'success': False, 'message': '不支持的文件格式'}), 400
    file.seek(0, 2)
    if file.tell() > MAX_FILE_SIZE:
        return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400
    file.seek(0)
    ext = file.filename.rsplit('.', 1)[1].lower()
    filename = f"avatar_{user_data['id']}_{int(time.time())}.{ext}"
    os.makedirs(UPLOAD_FOLDER, exist_ok=True)
    filepath = os.path.join(UPLOAD_FOLDER, filename)
    file.save(filepath)
    url = f'/static/uploads/{filename}'
    user = User.query.get(user_data['id'])
    user.avatar = url
    db.session.commit()
    session['user'] = {**user_data, 'avatar': url}
    return jsonify({'success': True, 'url': url})

@app.route('/api/user/change-name', methods=['POST'])
@login_required
def api_change_name():
    user_data = session['user']
    user = User.query.get(user_data['id'])
    if not user:
        return jsonify({'success': False, 'message': '用户不存在'}), 404
    # 每月仅可修改一次
    if user.name_changed_at:
        from datetime import timedelta
        if datetime.utcnow() - user.name_changed_at < timedelta(days=30):
            remaining = 30 - (datetime.utcnow() - user.name_changed_at).days
            return jsonify({'success': False, 'message': f'每月仅可修改一次用户名，还需等待{remaining}天'}), 400
    data = request.get_json(force=True, silent=True)
    new_name = data.get('name', '').strip()
    if not new_name or len(new_name) > 80:
        return jsonify({'success': False, 'message': '用户名不能为空且不超过80字符'}), 400
    if new_name == user.name:
        return jsonify({'success': False, 'message': '新用户名与当前相同'}), 400
    existing = User.query.filter(User.name == new_name, User.id != user.id).first()
    if existing:
        return jsonify({'success': False, 'message': '该用户名已被使用'}), 400
    user.name = new_name
    user.name_changed_at = datetime.utcnow()
    db.session.commit()
    session['user'] = {**user_data, 'name': new_name}
    return jsonify({'success': True, 'message': '用户名修改成功', 'name': new_name})

# ========== 杯赛申请相关页面 ==========
@app.route('/apply-contest', methods=['GET', 'POST'])
@login_required
def apply_contest():
    if request.method == 'POST':
        user_id = session['user']['id']

        # 查找该用户的现有申请（不限状态）
        existing = ContestApplication.query.filter_by(user_id=user_id).first()

        # 如果存在申请，检查拒绝次数和冷却期
        if existing:
            # 检查是否被拒绝5次且在冷却期内
            if existing.rejection_count >= 5 and existing.last_rejected_at:
                from datetime import timedelta
                cooldown_end = existing.last_rejected_at + timedelta(days=30)
                if datetime.utcnow() < cooldown_end:
                    remaining_days = (cooldown_end - datetime.utcnow()).days + 1
                    flash(f'您的申请已被拒绝5次，需等待 {remaining_days} 天后才能再次申请', 'error')
                    return redirect(url_for('contest_list'))

        name = request.form.get('name')
        organizer = request.form.get('organizer')
        description = request.form.get('description')
        contact = request.form.get('contact')
        start_date = request.form.get('start_date')
        end_date = request.form.get('end_date')
        total_score = request.form.get('total_score', type=int)
        responsible_person = request.form.get('responsible_person')
        responsible_phone = request.form.get('responsible_phone')
        responsible_email = request.form.get('responsible_email')
        organization = request.form.get('organization')

        if not all([name, organizer, description, contact, start_date, end_date, total_score,
                     responsible_person, responsible_phone, responsible_email, organization]):
            flash('请填写所有必填项')
            return redirect(url_for('apply_contest'))
        if total_score < 1:
            flash('满分分数必须大于0')
            return redirect(url_for('apply_contest'))

        # 如果存在申请，更新它；否则创建新申请
        if existing:
            # 更新现有申请
            existing.name = name
            existing.organizer = organizer
            existing.description = description
            existing.contact = contact
            existing.start_date = start_date
            existing.end_date = end_date
            existing.total_score = total_score
            existing.responsible_person = responsible_person
            existing.responsible_phone = responsible_phone
            existing.responsible_email = responsible_email
            existing.organization = organization
            existing.status = 'pending'
            existing.applied_at = datetime.utcnow()
            existing.reviewed_at = None
            app = existing
            flash_msg = '申请已更新，请等待管理员审核'
        else:
            # 创建新申请
            app = ContestApplication(
                user_id=session['user']['id'],
                name=name,
                organizer=organizer,
                description=description,
                contact=contact,
                start_date=start_date,
                end_date=end_date,
                total_score=total_score,
                responsible_person=responsible_person,
                responsible_phone=responsible_phone,
                responsible_email=responsible_email,
                organization=organization
            )
            db.session.add(app)
            flash_msg = '申请已提交，请等待管理员审核'

        db.session.commit()

        # 通知所有管理员有新的杯赛申请
        admins = User.query.filter_by(role='admin').all()
        for admin_user in admins:
            add_notification(
                admin_user.id,
                'contest_application',
                f'用户 {session["user"]["name"]} 申请举办杯赛「{name}」，请审核。',
                from_user=session['user']['name'],
                post_id=app.id
            )
        flash(flash_msg)
        return redirect(url_for('contest_list'))
    return render_template('apply_contest.html')

@app.route('/admin/contest-applications')
@admin_required
def admin_contest_applications():
    apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all()
    return render_template('admin_contest_applications.html', apps=apps)

@app.route('/admin/contest-applications/<int:app_id>/approve', methods=['POST'])
@admin_required
def approve_contest_application(app_id):
    app = ContestApplication.query.get_or_404(app_id)
    if app.status != 'pending':
        flash('该申请已处理')
        return redirect(url_for('admin_contest_applications'))
    contest = Contest(
        name=app.name,
        organizer=app.organizer,
        description=app.description,
        start_date=app.start_date or '待定',
        end_date=app.end_date or '待定',
        total_score=app.total_score or 150,
        visible=False,
        status='upcoming',
        participants=0,
        created_by=session['user']['id'],
        responsible_person=app.responsible_person,
        responsible_phone=app.responsible_phone,
        responsible_email=app.responsible_email,
        organization=app.organization
    )
    db.session.add(contest)
    db.session.flush()
    membership = ContestMembership(
        user_id=app.user_id,
        contest_id=contest.id,
        role='owner'
    )
    db.session.add(membership)
    app.status = 'approved'
    app.reviewed_at = datetime.utcnow()
    app.rejection_count = 0  # 重置拒绝次数
    app.last_rejected_at = None  # 清除最后拒绝时间
    # 自动创建杯赛讨论群
    chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
                        creator_id=app.user_id, contest_id=contest.id)
    db.session.add(chatroom)
    db.session.flush()
    db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=app.user_id, role='admin'))
    # 通知申请人审核通过
    add_notification(app.user_id, 'contest_result',
                     f'您申请举办的杯赛「{app.name}」已通过审核！杯赛当前仅您和管理员可见，完善资料后请手动发布。', from_user='系统')
    db.session.commit()
    flash('申请已批准，杯赛已创建')
    return redirect(url_for('admin_contest_applications'))

@app.route('/admin/contest-applications/<int:app_id>/reject', methods=['POST'])
@admin_required
def reject_contest_application(app_id):
    app = ContestApplication.query.get_or_404(app_id)
    if app.status != 'pending':
        flash('该申请已处理')
        return redirect(url_for('admin_contest_applications'))
    app.status = 'rejected'
    app.reviewed_at = datetime.utcnow()
    app.rejection_count += 1
    app.last_rejected_at = datetime.utcnow()
    # 通知申请人审核未通过
    add_notification(app.user_id, 'contest_result',
                     f'您申请举办的杯赛「{app.name}」未通过审核。', from_user='系统')
    db.session.commit()
    flash('申请已拒绝')
    return redirect(url_for('admin_contest_applications'))

@app.route('/admin/contests/create', methods=['GET', 'POST'])
@admin_required
def admin_create_contest():
    if request.method == 'POST':
        name = request.form.get('name', '').strip()
        organizer = request.form.get('organizer', '').strip()
        description = request.form.get('description', '').strip()
        start_date = request.form.get('start_date', '').strip() or '待定'
        end_date = request.form.get('end_date', '').strip() or '待定'
        total_score = int(request.form.get('total_score', 150) or 150)
        status = request.form.get('status', 'upcoming')
        responsible_person = request.form.get('responsible_person', '').strip()
        responsible_phone = request.form.get('responsible_phone', '').strip()
        responsible_email = request.form.get('responsible_email', '').strip()
        organization = request.form.get('organization', '').strip()
        if not name or not organizer:
            flash('杯赛名称和主办方为必填项')
            return render_template('admin_create_contest.html')
        contest = Contest(
            name=name, organizer=organizer, description=description,
            start_date=start_date, end_date=end_date, total_score=total_score,
            visible=True, status=status, participants=0,
            created_by=session['user']['id'],
            responsible_person=responsible_person,
            responsible_phone=responsible_phone,
            responsible_email=responsible_email,
            organization=organization
        )
        db.session.add(contest)
        db.session.flush()
        membership = ContestMembership(
            user_id=session['user']['id'],
            contest_id=contest.id,
            role='owner'
        )
        db.session.add(membership)
        chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
                            creator_id=session['user']['id'], contest_id=contest.id)
        db.session.add(chatroom)
        db.session.flush()
        db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=session['user']['id'], role='admin'))
        db.session.commit()
        flash('杯赛已创建并发布')
        return redirect(url_for('contest_detail', contest_id=contest.id))
    return render_template('admin_create_contest.html')

# ========== 通知 API ==========
@app.route('/api/notifications')
@login_required
def api_notifications():
    notifs = Notification.query.filter_by(user_id=session['user']['id']).order_by(Notification.created_at.desc()).limit(50).all()
    result = []
    for n in notifs:
        item = {
            'id': n.id,
            'type': n.type,
            'content': n.content,
            'from_user': n.from_user,
            'post_id': n.post_id,
            'read': n.read,
            'created_at': n.created_at.strftime('%Y-%m-%d %H:%M')
        }
        if n.type == 'contest_application' and n.post_id:
            ca = ContestApplication.query.get(n.post_id)
            if ca:
                item['application_status'] = ca.status
        if n.type == 'teacher_application' and n.post_id:
            ta = TeacherApplication.query.get(n.post_id)
            if ta:
                item['application_status'] = ta.status
                item['application_id'] = ta.id
                contest = Contest.query.get(ta.contest_id)
                item['contest_name'] = contest.name if contest else ''
                applicant = User.query.get(ta.user_id)
                item['applicant_name'] = applicant.name if applicant else ''
        if n.type == 'friend_request' and n.post_id:
            fr = Friend.query.get(n.post_id)
            if fr:
                item['application_status'] = fr.status
                item['friend_request_id'] = fr.id
        result.append(item)
    return jsonify({'success': True, 'notifications': result})

@app.route('/api/notifications/unread-count')
@login_required
def api_notifications_unread_count():
    count = Notification.query.filter_by(user_id=session['user']['id'], read=False).count()
    return jsonify({'success': True, 'count': count})

@app.route('/api/notifications/<int:nid>/read', methods=['POST'])
@login_required
def api_mark_notification_read(nid):
    n = Notification.query.get_or_404(nid)
    if n.user_id != session['user']['id']:
        return jsonify({'success': False}), 403
    n.read = True
    db.session.commit()
    return jsonify({'success': True})

@app.route('/api/notifications/read-all', methods=['POST'])
@login_required
def api_mark_all_notifications_read():
    Notification.query.filter_by(user_id=session['user']['id'], read=False).update({'read': True})
    db.session.commit()
    return jsonify({'success': True})

@app.route('/api/notifications/<int:nid>', methods=['DELETE'])
@login_required
def api_delete_notification(nid):
    """删除单条通知（仅管理员可删除已处理的通知）"""
    n = Notification.query.get_or_404(nid)
    if n.user_id != session['user']['id']:
        return jsonify({'success': False, 'message': '无权操作'}), 403
    if session['user'].get('role') != 'admin':
        return jsonify({'success': False, 'message': '仅管理员可删除通知'}), 403
    db.session.delete(n)
    db.session.commit()
    return jsonify({'success': True})

@app.route('/notifications')
@login_required
def notifications_page():
    return redirect(url_for('chat'))

# ========== 系统公告管理 API ==========
@app.route('/api/system-notifications')
def api_system_notifications():
    """获取系统公告列表（所有人可见）"""
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 20, type=int)
    q = SystemNotification.query.order_by(SystemNotification.pinned.desc(), SystemNotification.created_at.desc())
    pagination = q.paginate(page=page, per_page=per_page, error_out=False)
    items = []
    for sn in pagination.items:
        author = User.query.get(sn.author_id)
        items.append({
            'id': sn.id, 'title': sn.title, 'content': sn.content,
            'pinned': sn.pinned,
            'author_name': author.name if author else '系统',
            'created_at': sn.created_at.strftime('%Y-%m-%d %H:%M'),
            'updated_at': sn.updated_at.strftime('%Y-%m-%d %H:%M') if sn.updated_at else ''
        })
    return jsonify({'success': True, 'notifications': items, 'total': pagination.total, 'pages': pagination.pages, 'page': page})

@app.route('/api/system-notifications', methods=['POST'])
@admin_required
def api_create_system_notification():
    """管理员发布系统公告"""
    data = request.get_json(force=True, silent=True) or {}
    title = (data.get('title') or '').strip()
    content = (data.get('content') or '').strip()
    if not title or not content:
        return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
    sn = SystemNotification(
        title=title, content=content,
        author_id=session['user']['id'],
        pinned=bool(data.get('pinned', False))
    )
    db.session.add(sn)
    db.session.commit()
    # 给所有用户发送个人通知
    admin_name = session['user'].get('name', '管理员')
    all_users = User.query.all()
    for u in all_users:
        add_notification(u.id, 'system_announcement', f'【{title}】{content}', from_user=admin_name)
    return jsonify({'success': True, 'message': '通知已发布', 'id': sn.id})

@app.route('/api/system-notifications/<int:sn_id>', methods=['PUT'])
@admin_required
def api_update_system_notification(sn_id):
    """管理员修改系统公告"""
    sn = SystemNotification.query.get_or_404(sn_id)
    data = request.get_json(force=True, silent=True) or {}
    if 'title' in data:
        sn.title = data['title'].strip()
    if 'content' in data:
        sn.content = data['content'].strip()
    if 'pinned' in data:
        sn.pinned = bool(data['pinned'])
    db.session.commit()
    return jsonify({'success': True, 'message': '公告已更新'})

@app.route('/api/system-notifications/<int:sn_id>', methods=['DELETE'])
@admin_required
def api_delete_system_notification(sn_id):
    """管理员删除系统公告"""
    sn = SystemNotification.query.get_or_404(sn_id)
    db.session.delete(sn)
    db.session.commit()
    return jsonify({'success': True, 'message': '公告已删除'})

@app.route('/admin/notifications')
@admin_required
def admin_notifications():
    return render_template('admin_notifications.html')

@app.route('/api/admin/search-users')
@admin_required
def api_admin_search_users():
    q = request.args.get('q', '').strip()
    if not q:
        return jsonify({'success': True, 'users': []})
    users = User.query.filter(User.name.contains(q)).limit(20).all()
    return jsonify({'success': True, 'users': [
        {'id': u.id, 'name': u.name, 'role': u.role} for u in users
    ]})

@app.route('/api/admin/send-private-notification', methods=['POST'])
@admin_required
def api_admin_send_private_notification():
    data = request.get_json(force=True, silent=True)
    user_ids = data.get('user_ids', [])
    content = data.get('content', '').strip()
    if not user_ids or not content:
        return jsonify({'success': False, 'message': '请选择用户并输入内容'}), 400
    admin_name = session['user'].get('name', '管理员')
    for uid in user_ids:
        add_notification(uid, 'system_announcement', content, from_user=admin_name)
    return jsonify({'success': True, 'message': f'已发送给 {len(user_ids)} 位用户'})

@app.route('/api/contest-applications/<int:app_id>/approve', methods=['POST'])
@admin_required
def api_approve_contest(app_id):
    ca = ContestApplication.query.get_or_404(app_id)
    if ca.status != 'pending':
        return jsonify({'success': False, 'message': '该申请已处理'}), 400
    contest = Contest(
        name=ca.name, organizer=ca.organizer, description=ca.description,
        start_date='待定', end_date='待定', status='upcoming',
        participants=0, created_by=session['user']['id']
    )
    db.session.add(contest)
    db.session.flush()
    db.session.add(ContestMembership(user_id=ca.user_id, contest_id=contest.id, role='owner'))
    ca.status = 'approved'
    ca.reviewed_at = datetime.utcnow()
    ca.rejection_count = 0  # 重置拒绝次数
    ca.last_rejected_at = None  # 清除最后拒绝时间
    chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群',
                        creator_id=ca.user_id, contest_id=contest.id)
    db.session.add(chatroom)
    db.session.flush()
    db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=ca.user_id, role='admin'))
    add_notification(ca.user_id, 'contest_result',
                     f'您申请举办的杯赛「{ca.name}」已通过审核！', from_user='系统')
    db.session.commit()
    return jsonify({'success': True, 'message': '已批准'})

@app.route('/api/teacher-applications/<int:app_id>/approve', methods=['POST'])
@login_required
def api_approve_teacher(app_id):
    user = session['user']
    ta = TeacherApplication.query.get_or_404(app_id)
    if ta.status != 'pending':
        return jsonify({'success': False, 'message': '该申请已处理'}), 400
    # 权限检查：管理员或该杯赛负责人
    if user['role'] != 'admin':
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first()
        if not membership:
            return jsonify({'success': False, 'message': '无权限'}), 403
    existing = ContestMembership.query.filter_by(user_id=ta.user_id, contest_id=ta.contest_id).first()
    if existing:
        ta.status = 'rejected'
        ta.reviewed_at = datetime.utcnow()
        ta.reviewed_by = user['id']
        db.session.commit()
        return jsonify({'success': False, 'message': '用户已是杯赛成员'}), 400

    # 生成一次性邀请码
    code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
    while InviteCode.query.filter_by(code=code_str).first():
        code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))

    invite = InviteCode(code=code_str, user_id=ta.user_id, application_id=ta.id)
    db.session.add(invite)

    ta.status = 'approved'
    ta.reviewed_at = datetime.utcnow()
    ta.reviewed_by = user['id']
    ta.rejection_count = 0  # 重置拒绝次数
    ta.last_rejected_at = None  # 清除最后拒绝时间

    contest = Contest.query.get(ta.contest_id)
    contest_name = contest.name if contest else ''

    # 通过私聊发送邀请码
    msg_content = (
        f'恭喜！您申请成为杯赛「{contest_name}」老师已通过审核。\n'
        f'请使用以下邀请码激活您的教师身份：\n\n'
        f'🎫 邀请码：{code_str}\n\n'
        f'请前往「申请成为老师」页面，在邀请码输入框中输入此码完成激活。\n'
        f'注意：此邀请码仅限您本人使用，且仅限一次。'
    )
    send_private_message(user['id'], ta.user_id, msg_content, msg_type='system')

    add_notification(ta.user_id, 'teacher_result',
                     f'您申请成为杯赛「{contest_name}」老师已通过审核，请查看私聊消息获取邀请码。',
                     from_user='系统')
    db.session.commit()
    return jsonify({'success': True, 'message': '已批准，邀请码已通过私聊发送给老师'})

@app.route('/api/teacher-applications/<int:app_id>/reject', methods=['POST'])
@login_required
def api_reject_teacher(app_id):
    user = session['user']
    ta = TeacherApplication.query.get_or_404(app_id)
    if ta.status != 'pending':
        return jsonify({'success': False, 'message': '该申请已处理'}), 400
    if user['role'] != 'admin':
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first()
        if not membership:
            return jsonify({'success': False, 'message': '无权限'}), 403
    ta.status = 'rejected'
    ta.reviewed_at = datetime.utcnow()
    ta.reviewed_by = user['id']
    ta.rejection_count += 1
    ta.last_rejected_at = datetime.utcnow()
    contest = Contest.query.get(ta.contest_id)
    add_notification(ta.user_id, 'teacher_result',
                     f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。',
                     from_user='系统')
    db.session.commit()
    return jsonify({'success': True, 'message': '已拒绝'})

@app.route('/api/contest-applications/<int:app_id>/reject', methods=['POST'])
@admin_required
def api_reject_contest(app_id):
    ca = ContestApplication.query.get_or_404(app_id)
    if ca.status != 'pending':
        return jsonify({'success': False, 'message': '该申请已处理'}), 400
    ca.status = 'rejected'
    ca.reviewed_at = datetime.utcnow()
    ca.rejection_count += 1
    ca.last_rejected_at = datetime.utcnow()
    add_notification(ca.user_id, 'contest_result',
                     f'您申请举办的杯赛「{ca.name}」未通过审核。', from_user='系统')
    db.session.commit()
    return jsonify({'success': True, 'message': '已拒绝'})

# ========== 教师申请路由（针对具体杯赛）==========
@app.route('/apply-teacher', methods=['GET', 'POST'])
@login_required
def apply_teacher():
    if request.method == 'POST':
        contest_id = request.form.get('contest_id')
        name = request.form.get('name', '').strip()
        email = request.form.get('email', '').strip()
        reason = request.form.get('reason', '').strip()

        if not contest_id or not name or not email or not reason:
            flash('请填写完整信息', 'error')
            return redirect(url_for('apply_teacher'))

        contest = Contest.query.get(contest_id)
        if not contest:
            flash('杯赛不存在', 'error')
            return redirect(url_for('apply_teacher'))

        user = session['user']

        # 检查是否已经是该杯赛的老师或负责人
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
        if membership and membership.role in ['owner', 'teacher']:
            flash('您已经是该杯赛的老师或负责人', 'error')
            return redirect(url_for('contest_detail', contest_id=contest_id))

        # 查找该用户对该杯赛的现有申请
        existing = TeacherApplication.query.filter_by(user_id=user['id'], contest_id=contest_id).first()

        # 如果存在申请，检查拒绝次数和冷却期
        if existing:
            # 检查是否被拒绝5次且在冷却期内
            if existing.rejection_count >= 5 and existing.last_rejected_at:
                from datetime import timedelta
                cooldown_end = existing.last_rejected_at + timedelta(days=30)
                if datetime.utcnow() < cooldown_end:
                    remaining_days = (cooldown_end - datetime.utcnow()).days + 1
                    flash(f'您对该杯赛的申请已被拒绝5次，需等待 {remaining_days} 天后才能再次申请', 'error')
                    return redirect(url_for('contest_detail', contest_id=contest_id))

        # 如果存在申请，更新它；否则创建新申请
        if existing:
            # 更新现有申请
            existing.name = name
            existing.email = email
            existing.reason = reason
            existing.status = 'pending'
            existing.applied_at = datetime.utcnow()
            existing.reviewed_at = None
            existing.reviewed_by = None
            appli = existing
            flash_msg = '申请已更新，管理员或杯赛负责人会尽快审核'
        else:
            # 创建新申请
            appli = TeacherApplication(
                user_id=user['id'],
                contest_id=contest_id,
                name=name,
                email=email,
                reason=reason
            )
            db.session.add(appli)
            flash_msg = '申请已提交，管理员或杯赛负责人会尽快审核'

        db.session.commit()

        # 通知杯赛负责人
        owner_memberships = ContestMembership.query.filter_by(contest_id=contest_id, role='owner').all()
        for om in owner_memberships:
            add_notification(om.user_id, 'teacher_application',
                             f'用户 {name} 申请成为杯赛「{contest.name}」的老师，请审核。',
                             from_user=user['name'], post_id=appli.id)
        # 通知所有管理员
        admins = User.query.filter_by(role='admin').all()
        for admin_user in admins:
            add_notification(admin_user.id, 'teacher_application',
                             f'用户 {name} 申请成为杯赛「{contest.name}」的老师，请审核。',
                             from_user=user['name'], post_id=appli.id)

        flash(flash_msg, 'success')
        return redirect(url_for('contest_detail', contest_id=contest_id))

    # GET 请求：显示申请表单，可传入 contest_id 预选
    contest_id = request.args.get('contest_id', type=int)
    contests = Contest.query.all()
    selected_contest = Contest.query.get(contest_id) if contest_id else None
    return render_template('apply_teacher.html', contests=contests, selected_contest=selected_contest)

# ========== 管理后台教师申请审核 ==========
@app.route('/admin/teacher-applications')
@login_required
def admin_teacher_applications():
    user = session['user']
    # 管理员可见所有申请，杯赛负责人只能看到自己负责杯赛的申请
    if user['role'] == 'admin':
        apps = TeacherApplication.query.filter_by(status='pending').order_by(TeacherApplication.applied_at.desc()).all()
    else:
        # 获取用户作为负责人的所有杯赛ID
        owned_contests = [m.contest_id for m in ContestMembership.query.filter_by(user_id=user['id'], role='owner').all()]
        if not owned_contests:
            apps = []
        else:
            apps = TeacherApplication.query.filter(
                TeacherApplication.status == 'pending',
                TeacherApplication.contest_id.in_(owned_contests)
            ).order_by(TeacherApplication.applied_at.desc()).all()
    return render_template('admin_teacher_applications.html', apps=apps)

@app.route('/admin/teacher-applications/<int:app_id>/approve', methods=['POST'])
@login_required
def approve_teacher_application(app_id):
    user = session['user']
    app = TeacherApplication.query.get_or_404(app_id)
    if app.status != 'pending':
        flash('该申请已处理', 'error')
        return redirect(url_for('admin_teacher_applications'))

    # 权限检查：管理员或该杯赛负责人
    if user['role'] != 'admin':
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first()
        if not membership:
            flash('您没有权限审批此申请', 'error')
            return redirect(url_for('admin_teacher_applications'))

    # 检查是否已经是该杯赛成员
    existing = ContestMembership.query.filter_by(user_id=app.user_id, contest_id=app.contest_id).first()
    if existing:
        app.status = 'rejected'  # 已存在，无法再次添加
        app.reviewed_at = datetime.utcnow()
        app.reviewed_by = user['id']
        db.session.commit()
        flash('用户已是杯赛成员，申请已拒绝', 'error')
        return redirect(url_for('admin_teacher_applications'))

    # 生成一次性邀请码 TC-XXXXXXXX
    code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
    while InviteCode.query.filter_by(code=code_str).first():
        code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))

    invite = InviteCode(code=code_str, user_id=app.user_id, application_id=app.id)
    db.session.add(invite)

    app.status = 'approved'
    app.reviewed_at = datetime.utcnow()
    app.reviewed_by = user['id']
    app.rejection_count = 0  # 重置拒绝次数
    app.last_rejected_at = None  # 清除最后拒绝时间

    contest = Contest.query.get(app.contest_id)
    contest_name = contest.name if contest else ''

    # 通过私聊发送邀请码给老师
    msg_content = (
        f'恭喜！您申请成为杯赛「{contest_name}」老师已通过审核。\n'
        f'请使用以下邀请码激活您的教师身份：\n\n'
        f'🎫 邀请码：{code_str}\n\n'
        f'请前往「申请成为老师」页面，在邀请码输入框中输入此码完成激活。\n'
        f'注意：此邀请码仅限您本人使用，且仅限一次。'
    )
    send_private_message(user['id'], app.user_id, msg_content, msg_type='system')

    # 通知申请人去查看消息
    add_notification(app.user_id, 'teacher_result',
                     f'您申请成为杯赛「{contest_name}」老师已通过审核，请查看私聊消息获取邀请码。',
                     from_user='系统')
    db.session.commit()

    flash('申请已批准，邀请码已通过私聊发送给老师', 'success')
    return redirect(url_for('admin_teacher_applications'))

@app.route('/admin/teacher-applications/<int:app_id>/reject', methods=['POST'])
@login_required
def reject_teacher_application(app_id):
    user = session['user']
    app = TeacherApplication.query.get_or_404(app_id)
    if app.status != 'pending':
        flash('该申请已处理', 'error')
        return redirect(url_for('admin_teacher_applications'))

    # 权限检查：管理员或该杯赛负责人
    if user['role'] != 'admin':
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first()
        if not membership:
            flash('您没有权限审批此申请', 'error')
            return redirect(url_for('admin_teacher_applications'))

    app.status = 'rejected'
    app.reviewed_at = datetime.utcnow()
    app.reviewed_by = user['id']
    app.rejection_count += 1
    app.last_rejected_at = datetime.utcnow()
    # 通知申请人
    contest = Contest.query.get(app.contest_id)
    add_notification(app.user_id, 'teacher_result',
                     f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。',
                     from_user='系统')
    db.session.commit()

    flash('申请已拒绝', 'success')
    return redirect(url_for('admin_teacher_applications'))

# ========== API 路由 ==========

@app.route('/api/activate-invite-code', methods=['POST'])
@login_required
def api_activate_invite_code():
    """老师输入邀请码激活教师身份"""
    user = session['user']
    data = request.get_json(force=True, silent=True) or {}
    code_str = (data.get('code') or '').strip()
    if not code_str:
        return jsonify({'success': False, 'message': '请输入邀请码'}), 400

    invite = InviteCode.query.filter_by(code=code_str).first()
    if not invite:
        return jsonify({'success': False, 'message': '邀请码不存在'}), 404
    if invite.user_id != user['id']:
        return jsonify({'success': False, 'message': '此邀请码不属于您'}), 403
    if invite.used:
        return jsonify({'success': False, 'message': '此邀请码已被使用'}), 400

    ta = TeacherApplication.query.get(invite.application_id)
    if not ta:
        return jsonify({'success': False, 'message': '关联的申请记录不存在'}), 404

    # 检查是否已是杯赛成员
    existing = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id).first()
    if existing:
        return jsonify({'success': False, 'message': '您已是该杯赛成员'}), 400

    # 创建杯赛成员（teacher）
    membership = ContestMembership(user_id=user['id'], contest_id=ta.contest_id, role='teacher')
    db.session.add(membership)

    # 自动加入杯赛讨论群
    chatroom = ChatRoom.query.filter_by(contest_id=ta.contest_id).first()
    if chatroom:
        existing_chat = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first()
        if not existing_chat:
            db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member'))

    # 标记邀请码已使用
    invite.used = True
    invite.used_at = datetime.utcnow()
    db.session.commit()

    contest = Contest.query.get(ta.contest_id)
    return jsonify({'success': True, 'message': f'激活成功！您已成为杯赛「{contest.name if contest else ""}」的老师。'})

@app.route('/api/captcha')
def api_captcha():
    global captcha_store
    captcha_text = ''.join(random.choices(string.digits, k=4))
    captcha_id = generate_captcha_id()
    image = ImageCaptcha(width=150, height=50, font_sizes=[40])
    data = image.generate(captcha_text)
    import base64
    img_base64 = base64.b64encode(data.read()).decode('utf-8')
    captcha_store[captcha_id] = {
        'text': captcha_text.lower(),
        'expires': time.time() + 300
    }
    expired = [k for k, v in captcha_store.items() if time.time() > v['expires']]
    for k in expired:
        del captcha_store[k]
    return jsonify({'captchaId': captcha_id, 'img': img_base64})

@app.route('/api/send-email-code', methods=['POST'])
def api_send_email_code():
    global captcha_store, email_codes
    data = request.get_json(force=True, silent=True)
    email = data.get('email', '')
    captcha_id = data.get('captchaId', '')
    captcha_text = data.get('captchaText', '')

    if not email:
        return jsonify({'success': False, 'message': '邮箱不能为空'}), 400
    if not captcha_id or not captcha_text:
        return jsonify({'success': False, 'message': '请输入图形验证码'}), 400

    record = captcha_store.get(captcha_id)
    if not record or time.time() > record['expires']:
        captcha_store.pop(captcha_id, None)
        return jsonify({'success': False, 'message': '图形验证码已过期', 'refreshCaptcha': True}), 400
    if record['text'] != captcha_text.lower():
        captcha_store.pop(captcha_id, None)
        return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400
    captcha_store.pop(captcha_id, None)

    code = generate_code(6)
    email_codes[email] = {'code': code, 'expires': time.time() + 600}

    try:
        send_email(email, '您的注册验证码',
                   f'<p>您的验证码是：<b>{code}</b>，10分钟内有效。</p>')
        print(f'邮箱验证码已发送到 {email}: {code}')
        return jsonify({'success': True, 'message': '验证码已发送到邮箱'})
    except Exception as e:
        print(f'发送邮件失败: {e}')
        email_codes.pop(email, None)
        return jsonify({'success': False, 'message': f'发送邮件失败: {str(e)}'}), 500

# ========== 注册相关 API ==========

@app.route('/api/register', methods=['POST'])
def api_register():
    """邮箱注册（需绑定手机号）"""
    global email_codes
    data = request.get_json(force=True, silent=True)
    name = data.get('name', '')
    email = data.get('email', '')
    phone = data.get('phone', '')
    password = data.get('password', '')
    email_code = data.get('emailCode', '')

    if not name or not email or not password or not phone:
        return jsonify({'success': False, 'message': '请填写完整信息'}), 400
    if not email_code:
        return jsonify({'success': False, 'message': '请输入邮箱验证码'}), 400
    if not phone.isdigit() or len(phone) != 11:
        return jsonify({'success': False, 'message': '手机号格式不正确'}), 400

    record = email_codes.get(email)
    if not record or time.time() > record['expires']:
        email_codes.pop(email, None)
        return jsonify({'success': False, 'message': '邮箱验证码已过期', 'refreshCaptcha': True}), 400
    if record['code'] != email_code:
        record.setdefault('attempts', 0)
        record['attempts'] += 1
        if record['attempts'] >= 5:
            email_codes.pop(email, None)
            return jsonify({'success': False, 'message': '验证码错误次数过多，请重新获取', 'refreshCaptcha': True}), 400
        return jsonify({'success': False, 'message': f'邮箱验证码错误，还可尝试{5 - record["attempts"]}次'}), 400
    email_codes.pop(email, None)

    if User.query.filter_by(email=email).first():
        return jsonify({'success': False, 'message': '该邮箱已注册'}), 400
    if User.query.filter_by(phone=phone).first():
        return jsonify({'success': False, 'message': '该手机号已绑定其他账户'}), 400

    role = 'student'
    user = User(name=name, email=email, phone=phone, password=password, role=role)
    db.session.add(user)
    db.session.commit()

    user_data = {'name': user.name, 'email': user.email, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
    session['user'] = user_data
    return jsonify({'success': True, 'message': '注册成功', 'user': user_data})

@app.route('/api/register-mobile', methods=['POST'])
def api_register_mobile():
    """手机号注册"""
    data = request.get_json(force=True, silent=True)
    name = data.get('name', '')
    phone = data.get('phone', '')
    password = data.get('password', '')
    sms_code = data.get('smsCode', '')

    if not name or not phone or not password or not sms_code:
        return jsonify({'success': False, 'message': '请填写完整信息'}), 400
    if not phone.isdigit() or len(phone) != 11:
        return jsonify({'success': False, 'message': '手机号格式不正确'}), 400

    try:
        req = CommonRequest()
        req.set_accept_format('json')
        req.set_domain('dypnsapi.aliyuncs.com')
        req.set_method('POST')
        req.set_protocol_type('https')
        req.set_version('2017-05-25')
        req.set_action_name('CheckSmsVerifyCode')
        req.add_query_param('PhoneNumber', phone)
        req.add_query_param('VerifyCode', sms_code)

        response = acs_client.do_action_with_exception(req)
        result = json.loads(response)
        print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}')
        model = result.get('Model', {})
        if not (result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS'):
            return jsonify({'success': False, 'message': '短信验证码错误或已过期'}), 400
    except Exception as e:
        print(f'验证短信验证码出错: {e}')
        return jsonify({'success': False, 'message': '短信验证码验证失败'}), 500

    if User.query.filter_by(phone=phone).first():
        return jsonify({'success': False, 'message': '该手机号已注册'}), 400

    role = 'student'
    user = User(name=name, phone=phone, password=password, role=role)
    db.session.add(user)
    db.session.commit()

    user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
    session['user'] = user_data
    return jsonify({'success': True, 'message': '注册成功', 'user': user_data})

# ========== 登录相关 API ==========

@app.route('/api/login', methods=['POST'])
def api_login():
    data = request.get_json(force=True, silent=True)
    if not data:
        return jsonify({'success': False, 'message': '请求数据格式错误'}), 400
    email = data.get('email', '')
    password = data.get('password', '')
    remember = data.get('remember', False)

    if not email or not password:
        return jsonify({'success': False, 'message': '请输入邮箱和密码'}), 400

    user = User.query.filter_by(email=email).first()
    if not user:
        return jsonify({'success': False, 'message': '账号不存在，请先注册'}), 400
    if user.password != password:
        return jsonify({'success': False, 'message': '密码错误'}), 400

    user_data = {'name': user.name, 'email': user.email, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
    session['user'] = user_data

    # 如果勾选了"保持登录"，设置session为永久（10天）
    if remember:
        session.permanent = True

    return jsonify({'success': True, 'message': '登录成功', 'user': user_data})

@app.route('/api/send-sms', methods=['POST'])
def api_send_sms():
    global captcha_store
    data = request.get_json(force=True, silent=True)
    phone = data.get('phone', '')
    captcha_id = data.get('captchaId', '')
    captcha_text = data.get('captchaText', '')

    if not phone:
        return jsonify({'success': False, 'message': '手机号不能为空'}), 400
    if not captcha_id or not captcha_text:
        return jsonify({'success': False, 'message': '请输入图形验证码'}), 400

    record = captcha_store.get(captcha_id)
    if not record or time.time() > record['expires']:
        captcha_store.pop(captcha_id, None)
        return jsonify({'success': False, 'message': '图形验证码已过期，请刷新', 'refreshCaptcha': True}), 400
    if record['text'] != captcha_text.lower():
        captcha_store.pop(captcha_id, None)
        return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400
    captcha_store.pop(captcha_id, None)

    try:
        req = CommonRequest()
        req.set_accept_format('json')
        req.set_domain('dypnsapi.aliyuncs.com')
        req.set_method('POST')
        req.set_protocol_type('https')
        req.set_version('2017-05-25')
        req.set_action_name('SendSmsVerifyCode')
        req.add_query_param('PhoneNumber', phone)
        req.add_query_param('SignName', os.getenv('SIGN_NAME', ''))
        req.add_query_param('TemplateCode', os.getenv('TEMPLATE_CODE', ''))
        req.add_query_param('TemplateParam', json.dumps({'code': '##code##', 'min': '5'}))
        req.add_query_param('CountryCode', '86')
        req.add_query_param('ValidTime', 300)
        req.add_query_param('Interval', 60)
        req.add_query_param('ReturnVerifyCode', True)
        req.add_query_param('CodeType', 1)
        req.add_query_param('CodeLength', 4)

        response = acs_client.do_action_with_exception(req)
        result = json.loads(response)
        print(f'号码认证服务返回: {json.dumps(result, ensure_ascii=False, indent=2)}')

        if result.get('Code') == 'OK':
            resp_data = {'success': True, 'message': '验证码发送成功'}
            model = result.get('Model', {})
            if model.get('VerifyCode'):
                print(f'验证码: {model["VerifyCode"]}')
            return jsonify(resp_data)
        else:
            print(f'发送失败: {result.get("Message")}')
            return jsonify({'success': False, 'message': result.get('Message', '发送失败')}), 500
    except Exception as e:
        print(f'发送短信出错: {e}')
        return jsonify({'success': False, 'message': f'发送短信出错: {str(e)}'}), 500

@app.route('/api/verify-code', methods=['POST'])
def api_verify_code():
    data = request.get_json(force=True, silent=True)
    phone = data.get('phone', '')
    code = data.get('code', '')
    remember = data.get('remember', False)

    if not phone or not code:
        return jsonify({'success': False, 'message': '手机号和验证码不能为空'}), 400

    try:
        req = CommonRequest()
        req.set_accept_format('json')
        req.set_domain('dypnsapi.aliyuncs.com')
        req.set_method('POST')
        req.set_protocol_type('https')
        req.set_version('2017-05-25')
        req.set_action_name('CheckSmsVerifyCode')
        req.add_query_param('PhoneNumber', phone)
        req.add_query_param('VerifyCode', code)

        response = acs_client.do_action_with_exception(req)
        result = json.loads(response)
        print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}')

        model = result.get('Model', {})
        if result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS':
            user = User.query.filter_by(phone=phone).first()
            if not user:
                user = User(
                    name=f'用户{phone[-4:]}',
                    phone=phone,
                    password=generate_code(8),
                    role='student'
                )
                db.session.add(user)
                db.session.commit()
            user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''}
            session['user'] = user_data

            # 如果勾选了"保持登录"，设置session为永久（10天）
            if remember:
                session.permanent = True

            return jsonify({'success': True, 'message': '验证成功', 'user': user_data})
        else:
            msg = '验证码已过期' if model.get('VerifyResult') == 'UNKNOWN' else '验证码错误'
            return jsonify({'success': False, 'message': msg}), 400
    except Exception as e:
        print(f'验证出错: {e}')
        return jsonify({'success': False, 'message': '验证码错误或已过期'}), 400

# ========== 图片上传 API ==========

UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/api/upload', methods=['POST'])
def api_upload():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    if 'file' not in request.files:
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'success': False, 'message': '没有选择文件'}), 400
    if not allowed_file(file.filename):
        return jsonify({'success': False, 'message': '不支持的文件格式，请上传 PNG/JPG/GIF/WebP'}), 400
    # 检查文件大小
    file.seek(0, 2)
    size = file.tell()
    file.seek(0)
    if size > MAX_FILE_SIZE:
        return jsonify({'success': False, 'message': '文件大小不能超过10MB'}), 400
    # 生成唯一文件名
    ext = file.filename.rsplit('.', 1)[1].lower()
    filename = f"{int(time.time())}_{random.randint(1000,9999)}.{ext}"
    os.makedirs(UPLOAD_FOLDER, exist_ok=True)
    filepath = os.path.join(UPLOAD_FOLDER, filename)
    file.save(filepath)
    url = f'/static/uploads/{filename}'
    return jsonify({'success': True, 'url': url, 'filename': filename})

# ========== AI 诊断接口（测试完可删除） ==========
@app.route('/api/ai-diagnose')
def api_ai_diagnose():
    """诊断 DashScope API 连通性"""
    import httpx
    results = {}

    # 1. 检查 API key 配置
    api_key = os.getenv('DASHSCOPE_API_KEY', '')
    if not api_key or api_key == 'sk-xxxxxxxxxxxxx':
        results['api_key'] = '未配置'
        return jsonify(results)
    results['api_key'] = f'{api_key[:8]}...{api_key[-4:]}' if len(api_key) > 12 else '已配置(太短，可能无效)'

    # 2. 测试网络连通性（跳过SSL验证）
    try:
        resp = httpx.get('https://dashscope.aliyuncs.com', timeout=10, follow_redirects=True, verify=False)
        results['network'] = f'连通，状态码 {resp.status_code}'
    except Exception as e:
        results['network'] = f'不通: {str(e)}'
        return jsonify(results)

    # 3. 测试 API 调用（最简单的请求）
    try:
        client = DashScopeClient(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
            http_client=httpx.Client(verify=False)
        )
        response = client.chat.completions.create(
            model="qwen-turbo",
            messages=[{"role": "user", "content": "回复OK"}],
            max_tokens=10
        )
        results['api_call'] = f'成功: {response.choices[0].message.content.strip()}'
    except Exception as e:
        results['api_call'] = f'失败: {type(e).__name__}: {str(e)}'

    # 4. 测试视觉模型
    try:
        client2 = DashScopeClient(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
            http_client=httpx.Client(verify=False)
        )
        response2 = client2.chat.completions.create(
            model="qwen-vl-plus",
            messages=[{"role": "user", "content": [{"type": "text", "text": "回复OK"}]}],
            max_tokens=10
        )
        results['vision_model'] = f'成功: {response2.choices[0].message.content.strip()}'
    except Exception as e:
        results['vision_model'] = f'失败: {type(e).__name__}: {str(e)}'

    return jsonify(results)

# ========== PDF 智能识别 ==========

@app.route('/api/parse-pdf', methods=['POST'])
def api_parse_pdf():
    user = session.get('user')
    if not user or user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '仅教师可使用此功能'}), 403
    if 'file' not in request.files:
        return jsonify({'success': False, 'message': '请选择PDF文件'}), 400
    file = request.files['file']
    if not file.filename.lower().endswith('.pdf'):
        return jsonify({'success': False, 'message': '仅支持PDF格式文件'}), 400
    # 检查文件大小 ≤ 20MB
    file.seek(0, 2)
    size = file.tell()
    file.seek(0)
    if size > 20 * 1024 * 1024:
        return jsonify({'success': False, 'message': '文件大小不能超过20MB'}), 400

    api_key = os.getenv('DASHSCOPE_API_KEY', '')
    if not api_key or api_key == 'sk-xxxxxxxxxxxxx':
        return jsonify({'success': False, 'message': '未配置AI接口密钥，请联系管理员在.env中设置DASHSCOPE_API_KEY'}), 500

    import base64 as _b64
    import re as _pdf_re
    import uuid as _uuid

    # 读取PDF，将每页渲染为图片 + 提取文本
    try:
        pdf_bytes = file.read()
        doc = fitz.open(stream=pdf_bytes, filetype="pdf")
        page_images = []  # base64 编码的页面图片（发给AI）
        page_texts = []   # 每页提取的文本
        page_image_urls = []  # 保存到磁盘的页面图片URL
        page_sizes = []   # 每页的像素尺寸 (w, h)
        max_pages = 20    # 最多处理20页
        _dpi_scale = 150 / 72
        for i, page in enumerate(doc):
            if i >= max_pages:
                break
            # 提取文本
            page_texts.append(page.get_text("text", sort=True))
            # 渲染页面为图片（150 DPI，平衡清晰度和大小）
            mat = fitz.Matrix(_dpi_scale, _dpi_scale)
            pix = page.get_pixmap(matrix=mat)
            img_bytes = pix.tobytes("png")
            img_b64 = _b64.b64encode(img_bytes).decode('utf-8')
            page_images.append(img_b64)
            page_sizes.append((pix.width, pix.height))
            # 保存页面图片到磁盘，供题目引用
            img_filename = f"pdf_page_{_uuid.uuid4().hex[:8]}_{i+1}.png"
            img_path = os.path.join(UPLOAD_FOLDER, img_filename)
            with open(img_path, 'wb') as f:
                f.write(img_bytes)
            page_image_urls.append(f'/static/uploads/{img_filename}')
        doc.close()
    except Exception as e:
        return jsonify({'success': False, 'message': f'PDF读取失败: {str(e)}'}), 400

    all_text = "\n".join(page_texts)
    has_text = bool(all_text.strip())

    # 判断是否包含图形（文本量少或有几何关键词）
    has_figures = not has_text or len(all_text.strip()) < 200
    figure_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形',
                       '圆', '棱', '锥', '柱', '球', '坐标', '函数图', '图像', '图形',
                       '电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图',
                       '表格', '数轴', '向量', '抛物线', '双曲线', '椭圆', '正弦', '余弦',
                       '结构式', '分子式', '装置图', '流程图', '框图', '韦恩图']
    if any(kw in all_text for kw in figure_keywords):
        has_figures = True

    # 构造系统提示
    system_prompt = """你是一个顶级专业的试卷解析助手，擅长处理包含几何图形、数学公式、物理电路图、化学结构式等的各类试卷。你必须做到100%准确识别每一道题目，不遗漏、不合并、不截断。

关键规则：
- 仔细逐页观察每页图片，识别所有题目（包括大题下的小题），包括图片中的几何图形、坐标图、函数图像等
- 如果题目包含图形（如立体几何、平面几何、函数图像、电路图、实验装置图等），在content中用文字非常详细地描述图形内容，例如："（图：正三棱柱ABC-A₁B₁C₁，其中AB=2，AA₁=3，M为BB₁中点，连接AM、CM）"
- 所有数学公式必须用LaTeX格式，并用 $ 符号包裹。例如：
  - 行内公式：$x^2+2x+1=0$、$\\sqrt{2}$、$\\frac{a}{b}$、$\\int_0^1 f(x)dx$
  - 分段函数：$f(x)=\\begin{cases} 2^x+1, & x>1 \\\\ ax^2+(b-3)x, & x\\leq 1 \\end{cases}$
  - 希腊字母：$\\alpha$、$\\beta$、$\\theta$、$\\pi$
  - 集合符号：$\\in$、$\\subset$、$\\cup$、$\\cap$、$\\emptyset$
  - 几何符号：$\\perp$、$\\parallel$、$\\triangle$、$\\angle$
  - 不等号：$\\geq$、$\\leq$、$\\neq$、$\\pm$、$\\infty$
  - 求和/积分：$\\sum_{i=1}^{n}$、$\\int$、$\\lim$
  - 分数：$\\frac{a}{b}$  指数：$x^{2}$  下标：$a_{1}$
  - 根号：$\\sqrt{3}$  向量：$\\vec{a}$  绝对值：$|x|$
  - 矩阵：$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$
  - 对数：$\\log$、$\\ln$、$\\lg$
- 化学式也用LaTeX：$\\text{H}_2\\text{O}$、$\\text{CO}_2$、$\\text{Fe}_2\\text{O}_3$
- 选项中的公式也必须用 $ 包裹
- 每道题必须完整，不要截断或合并
- 注意区分大题和小题：如果一道大题下有(1)(2)(3)等小题，每个小题单独作为一个题目输出，content中注明属于哪道大题
- 严格只输出JSON，不要有任何其他文字
- 每道题必须标注 has_figure 字段：如果该题包含或引用了图形（几何图、函数图像、坐标图、电路图、实验装置图、结构式图等），设为 true；纯文字题设为 false
- 如果 has_figure 为 true，必须提供 figure_bbox 字段，格式为 [x0, y0, x1, y1]，表示图形在该页图片中的大致位置比例（0~1之间的小数），其中 (x0,y0) 是左上角，(x1,y1) 是右下角。例如图形在页面右半部分中间：[0.5, 0.3, 0.95, 0.7]

【题型识别指南】
- choice: 有A/B/C/D选项的选择题（含单选和多选）
- fill: 填空题（含有____或括号需要填写的）
- judge: 判断题（判断对错/正误/是否）
- text: 解答题、证明题、计算题、简答题、论述题、作图题等

【输出格式强制要求】
- 你的回复必须是一个合法的JSON数组，以 [ 开头，以 ] 结尾
- 禁止输出任何解释、注释、markdown标记（如```）、前言或后语
- 直接输出JSON，不要包裹在代码块中

示例输出格式：
[{"type":"choice","content":"已知函数 $f(x)=x^2+2x+1$，则 $f(0)$ 的值为","options":["$0$","$1$","$2$","$3$"],"answer":"B","score":5,"page":1,"has_figure":false},{"type":"fill","content":"计算 $\\\\sin 30°=$ ____","options":[],"answer":"$\\\\frac{1}{2}$","score":5,"page":1,"has_figure":false},{"type":"text","content":"（第18题第(1)小题）已知数列 $\\\\{a_n\\\\}$ 满足 $a_1=1$，$a_{n+1}=2a_n+1$，求通项公式 $a_n$","options":[],"answer":"","score":6,"page":2,"has_figure":false}]"""

    user_content_parts = []

    # 如果有图形或扫描件，使用视觉模型发送图片
    use_vision = has_figures or not has_text
    if use_vision:
        user_content_parts.append({
            "type": "text",
            "text": """请仔细查看以下试卷页面图片，识别所有题目并解析为JSON数组。
特别注意：
1. 识别图片中的几何图形、坐标系、函数图像等，用文字描述在content中
2. 不要遗漏任何题目
3. 保留所有数学符号和公式
4. 如果题目包含或引用了图形，has_figure设为true，并用figure_bbox标注图形在该页中的位置比例[x0,y0,x1,y1]（0~1之间）

每个元素格式：
{"type":"choice/fill/text/judge", "content":"题目内容（含图形描述）", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false, "figure_bbox":[x0,y0,x1,y1]}
- options 仅选择题需要
- answer: 选择题A/B/C/D，判断题"A"=正确/"B"=错误，其他填参考答案或""
- score: 按试卷标注，未标注则选择题5、填空题5、判断题3、解答题10
- page: 该题所在的PDF页码（从1开始），必须填写
- has_figure: 该题是否包含图形，true/false
- figure_bbox: 仅has_figure为true时需要，图形在页面中的位置比例[x0,y0,x1,y1]"""
        })
        # 添加页面图片（最多10页发给视觉模型）
        for i, img_b64 in enumerate(page_images[:10]):
            user_content_parts.append({
                "type": "image_url",
                "image_url": {"url": f"data:image/png;base64,{img_b64}"}
            })
        # 如果有提取到的文本，也附上作为辅助
        if has_text:
            clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:20000]
            user_content_parts.append({
                "type": "text",
                "text": f"\n以下是PDF提取的文字内容作为辅助参考：\n{clean_text}"
            })
    else:
        # 纯文本模式
        clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:30000]
        user_content_parts.append({
            "type": "text",
            "text": f"""请将以下试卷内容解析为JSON数组。每个元素格式：
{{"type":"choice/fill/text/judge", "content":"题目内容", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false}}
- options 仅选择题需要
- answer: 选择题A/B/C/D，判断题"A"=正确/"B"=错误，其他填参考答案或""
- score: 按试卷标注，未标注则选择题5、填空题5、判断题3、解答题10
- page: 该题所在的大致页码（从1开始），必须填写
- has_figure: 该题是否包含或引用了图形（如"如图所示"），true/false

试卷内容：
{clean_text}"""
        })

    # --- 健壮的 JSON 清洗函数 ---
    def _clean_and_parse_json(raw_text):
        """尝试从 AI 返回文本中提取合法 JSON 数组"""
        text = raw_text.strip()
        # 去除 BOM 头
        text = text.lstrip('\ufeff')
        # 去除所有 markdown 代码围栏（支持多段）
        text = _pdf_re.sub(r'```(?:json)?[ \t]*\n?', '', text)
        text = _pdf_re.sub(r'```', '', text)
        text = text.strip()
        # 尝试直接解析
        parsed = _try_json_loads(text)
        if parsed is not None:
            return parsed
        # 找到第一个 [ 和最后一个 ] 之间的内容
        first_bracket = text.find('[')
        last_bracket = text.rfind(']')
        if first_bracket != -1 and last_bracket > first_bracket:
            subset = text[first_bracket:last_bracket + 1]
            parsed = _try_json_loads(subset)
            if parsed is not None:
                return parsed
            # 尝试修复尾部多余逗号: ,] -> ]
            fixed = _pdf_re.sub(r',\s*([}\]])', r'\1', subset)
            parsed = _try_json_loads(fixed)
            if parsed is not None:
                return parsed
        return None

    def _try_json_loads(text):
        """解析 JSON，返回题目列表或 None"""
        try:
            obj = json.loads(text)
        except (json.JSONDecodeError, ValueError):
            return None
        if isinstance(obj, list) and len(obj) > 0:
            return obj
        if isinstance(obj, dict):
            for key in ['questions', 'data', 'items', 'result']:
                if key in obj and isinstance(obj[key], list):
                    return obj[key]
        return None

    # --- 调用 AI ---
    try:
        import httpx
        client = DashScopeClient(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
            http_client=httpx.Client(verify=False, timeout=httpx.Timeout(120.0, connect=30.0))
        )
        model = "qwen-vl-plus" if use_vision else "qwen-turbo"
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_content_parts}
        ]
        call_kwargs = dict(model=model, messages=messages, temperature=0.01, max_tokens=8192)
        # qwen-plus 支持 response_format，视觉模型不支持
        if not use_vision:
            call_kwargs["response_format"] = {"type": "json_object"}
        response = client.chat.completions.create(**call_kwargs)
        result_text = response.choices[0].message.content.strip()
    except Exception as e:
        return jsonify({'success': False, 'message': f'AI解析失败: {str(e)}'}), 500

    # --- 解析 + 自动重试 ---
    questions = _clean_and_parse_json(result_text)

    if questions is None:
        # 解析失败，打印调试日志
        app.logger.warning("AI首次返回解析失败，原始内容前500字符: %s", result_text[:500])
        # 自动重试：把原始文本发给 AI 做格式修正（仅 1 次）
        try:
            retry_messages = [
                {"role": "system", "content": "你是一个JSON格式修正助手。用户会给你一段包含试卷题目信息但格式不正确的文本，请将其转换为合法的JSON数组。只输出JSON数组，以[开头，以]结尾，禁止输出任何其他文字。"},
                {"role": "user", "content": f"请将以下文本转换为合法JSON数组：\n{result_text[:16000]}"}
            ]
            retry_kwargs = dict(model="qwen-turbo", messages=retry_messages, temperature=0.01,
                                max_tokens=8192, response_format={"type": "json_object"})
            retry_resp = client.chat.completions.create(**retry_kwargs)
            retry_text = retry_resp.choices[0].message.content.strip()
            questions = _clean_and_parse_json(retry_text)
            if questions is None:
                app.logger.warning("AI重试返回仍解析失败，原始内容前500字符: %s", retry_text[:500])
        except Exception as e:
            app.logger.warning("AI重试请求异常: %s", str(e))

    if not questions or not isinstance(questions, list) or len(questions) == 0:
        return jsonify({'success': False, 'message': 'AI返回格式异常，请重试或手动录入'}), 500

    # 图形关键词兜底检测
    _fig_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形',
                     '圆', '棱', '锥', '柱', '球', '坐标', '函数图', '图像', '图形',
                     '电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图',
                     '表格', '数轴', '向量', '抛物线', '双曲线', '椭圆',
                     '结构式', '分子式', '装置图', '流程图', '框图']

    def _detect_has_figure(q_item):
        """判断题目是否含图形：优先用AI返回的has_figure，否则关键词检测"""
        val = q_item.get('has_figure')
        if val is True or val == 'true' or val == 1:
            return True
        if val is False or val == 'false' or val == 0:
            return False
        # AI 没返回该字段，用关键词兜底
        content = q_item.get('content', '')
        return any(kw in content for kw in _fig_keywords)

    def _crop_page_image(page_idx, bbox):
        """用 fitz 从页面图片中裁剪指定区域，返回保存后的 URL 或 None"""
        if page_idx < 0 or page_idx >= len(page_images):
            return None
        if not bbox or not isinstance(bbox, list) or len(bbox) != 4:
            return None
        try:
            w, h = page_sizes[page_idx]
            x0 = max(0, int(bbox[0] * w) - 20)
            y0 = max(0, int(bbox[1] * h) - 20)
            x1 = min(w, int(bbox[2] * w) + 20)
            y1 = min(h, int(bbox[3] * h) + 20)
            if x1 - x0 < 10 or y1 - y0 < 10:
                return None
            # 用 fitz 从 base64 解码后裁剪
            img_data = _b64.b64decode(page_images[page_idx])
            src_pix = fitz.Pixmap(img_data)
            clip = fitz.IRect(x0, y0, x1, y1)
            cropped_pix = fitz.Pixmap(src_pix, clip)
            crop_bytes = cropped_pix.tobytes("png")
            crop_filename = f"pdf_crop_{_uuid.uuid4().hex[:8]}.png"
            crop_path = os.path.join(UPLOAD_FOLDER, crop_filename)
            with open(crop_path, 'wb') as f:
                f.write(crop_bytes)
            return f'/static/uploads/{crop_filename}'
        except Exception:
            return None

    # 只为含图形的题目附加图片（裁剪优先，整页兜底）
    referenced_urls = set()
    for q in questions:
        has_fig = _detect_has_figure(q)
        page_num = q.get('page')
        bbox = q.get('figure_bbox')

        if has_fig and page_num and isinstance(page_num, (int, float)):
            idx = int(page_num) - 1
            # 尝试裁剪
            crop_url = _crop_page_image(idx, bbox)
            if crop_url:
                q.setdefault('images', [])
                q['images'].append(crop_url)
                referenced_urls.add(crop_url)
            elif 0 <= idx < len(page_image_urls):
                # 裁剪失败，回退整页
                q.setdefault('images', [])
                if page_image_urls[idx] not in q['images']:
                    q['images'].append(page_image_urls[idx])
                referenced_urls.add(page_image_urls[idx])

        # 清理前端不需要的字段
        q.pop('page', None)
        q.pop('has_figure', None)
        q.pop('figure_bbox', None)

    # 清理未被引用的整页图片文件
    for url in page_image_urls:
        if url not in referenced_urls:
            try:
                unused_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), url.lstrip('/'))
                if os.path.exists(unused_path):
                    os.remove(unused_path)
            except Exception:
                pass

    return jsonify({'success': True, 'questions': questions})

# ========== 考试系统 API ==========

@app.route('/api/exams', methods=['POST'])
def api_create_exam():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    data = request.get_json(force=True, silent=True)
    contest_id = data.get('contest_id')

    # 杯赛考试：只有杯赛负责人或系统管理员可以创建
    if contest_id:
        contest = Contest.query.get(contest_id)
        if not contest or contest.status == 'abolished':
            return jsonify({'success': False, 'message': '杯赛不存在或已废止'}), 400
        membership = ContestMembership.query.filter_by(
            user_id=user['id'], contest_id=contest_id, role='owner').first()
        if not membership and user.get('role') != 'admin':
            return jsonify({'success': False, 'message': '只有杯赛负责人才能组织考试'}), 403
    else:
        # 普通考试：需要 teacher 或 admin 角色
        if user.get('role') not in ('teacher', 'admin'):
            return jsonify({'success': False, 'message': '无权限'}), 403

    title = data.get('title', '')
    subject = data.get('subject', '')
    duration = data.get('duration', 120)
    questions = data.get('questions', [])
    scheduled_start = data.get('scheduled_start', '')
    scheduled_end = data.get('scheduled_end', '')
    score_release_time = data.get('score_release_time', '')
    if not title or not questions:
        return jsonify({'success': False, 'message': '请填写试卷标题和题目'}), 400
    total_score = sum(q.get('score', 0) for q in questions)
    exam = Exam(
        title=title,
        subject=subject,
        duration=duration,
        total_score=total_score,
        creator_id=user.get('id'),
        contest_id=contest_id
    )
    # 解析预定时间
    if scheduled_start:
        try:
            exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M')
        except ValueError:
            pass
    if scheduled_end:
        try:
            exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M')
        except ValueError:
            pass
    if score_release_time:
        try:
            release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M')
            # 验证公布时间必须晚于考试结束时间
            if exam.scheduled_end and release_dt <= exam.scheduled_end:
                return jsonify({'success': False, 'message': '成绩公布时间必须晚于考试结束时间'}), 400
            exam.score_release_time = release_dt
        except ValueError:
            pass
    # 考试密码
    access_password = data.get('access_password', '').strip()
    if access_password:
        exam.access_password = access_password
    # 加密存储试卷内容
    questions_json = json.dumps(questions)
    exam.encrypted_questions = encrypt_questions(questions_json)
    exam.is_encrypted = True
    exam.set_questions(questions)  # 同时保留明文用于兼容
    db.session.add(exam)
    db.session.commit()
    # 通知杯赛已报名用户
    if contest_id:
        registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
        for reg in registrations:
            if reg.user_id != user['id']:
                add_notification(reg.user_id, 'contest_new_exam',
                    f'您报名的杯赛「{contest.name}」发布了新考试「{title}」，快去查看吧！',
                    from_user=user.get('name', ''), post_id=exam.id)
    return jsonify({'success': True, 'message': '试卷创建成功', 'exam_id': exam.id})

@app.route('/api/exams/<int:exam_id>/verify-password', methods=['POST'])
@login_required
def api_verify_exam_password(exam_id):
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '考试不存在'}), 404
    data = request.get_json(force=True, silent=True)
    password = data.get('password', '')
    if password == exam.access_password:
        session[f'exam_verified_{exam_id}'] = True
        return jsonify({'success': True, 'message': '验证通过'})
    return jsonify({'success': False, 'message': '密码错误'}), 403

@app.route('/api/exams/<int:exam_id>/save-draft', methods=['POST'])
def api_save_draft(exam_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '试卷不存在'}), 404
    data = request.get_json(force=True, silent=True)
    answers = data.get('answers', {})
    draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
    if not draft:
        draft = Draft(exam_id=exam_id, user_id=user.get('id'))
    draft.set_answers(answers)
    db.session.add(draft)
    db.session.commit()
    return jsonify({'success': True, 'message': '草稿已保存'})

@app.route('/api/exams/<int:exam_id>/submit', methods=['POST'])
def api_submit_exam(exam_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '试卷不存在'}), 404
    if exam.status == 'closed':
        return jsonify({'success': False, 'message': '该考试已关闭'}), 400
    # 检查预定时间
    now = datetime.utcnow()
    if exam.scheduled_start and now < exam.scheduled_start:
        return jsonify({'success': False, 'message': '考试尚未开始'}), 400
    if exam.scheduled_end and now > exam.scheduled_end:
        return jsonify({'success': False, 'message': '考试已结束'}), 400
    if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first():
        return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
    data = request.get_json(force=True, silent=True)
    answers = data.get('answers', {})
    score = 0
    auto_graded = True
    question_scores = {}
    questions = get_exam_questions(exam)
    for q in questions:
        qid = str(q['id'])
        if q['type'] == 'choice':
            if answers.get(qid) == q.get('answer'):
                score += q.get('score', 0)
                question_scores[qid] = q.get('score', 0)
            else:
                question_scores[qid] = 0
        elif q['type'] == 'fill':
            student_answer = answers.get(qid, '').strip()
            correct_answers = [a.strip() for a in q.get('answer', '').split('|')]
            if student_answer in correct_answers:
                score += q.get('score', 0)
                question_scores[qid] = q.get('score', 0)
            else:
                question_scores[qid] = 0
        else:
            auto_graded = False
            question_scores[qid] = 0
    sub = Submission(
        exam_id=exam_id,
        user_id=user.get('id'),
        score=score,
        graded=auto_graded,
        graded_by='系统自动' if auto_graded else ''
    )
    sub.set_answers(answers)
    sub.set_question_scores(question_scores)
    db.session.add(sub)
    Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete()
    db.session.commit()
    return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})

@app.route('/api/exams/<int:exam_id>/grade/<int:sub_id>', methods=['POST'])
def api_grade_submission(exam_id, sub_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    sub = Submission.query.get(sub_id)
    if not sub or sub.exam_id != exam_id:
        return jsonify({'success': False, 'message': '提交记录不存在'}), 404
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '考试不存在'}), 404
    if not can_grade_exam(user, exam):
        return jsonify({'success': False, 'message': '无权限'}), 403
    data = request.get_json(force=True, silent=True)
    scores = data.get('scores', {})
    total = sum(scores.values())
    sub.score = total
    sub.set_question_scores(scores)
    sub.graded = True
    sub.graded_by = user.get('name', '')
    db.session.commit()
    # 通知学生批改完成
    add_notification(sub.user_id, 'exam_graded',
        f'您在考试「{exam.title}」中的答卷已被批改，得分：{total}/{exam.total_score}',
        from_user=user.get('name', ''), post_id=exam.id)
    return jsonify({'success': True, 'message': '批改完成', 'total_score': total})

@app.route('/api/exams/<int:exam_id>/status', methods=['POST'])
def api_update_exam_status(exam_id):
    user = session.get('user')
    if not user or user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权限'}), 403
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '试卷不存在'}), 404
    if exam.creator_id != user.get('id'):
        return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403
    data = request.get_json(force=True, silent=True)
    new_status = data.get('status', '')
    if new_status not in ('available', 'closed'):
        return jsonify({'success': False, 'message': '无效状态'}), 400
    exam.status = new_status
    db.session.commit()
    return jsonify({'success': True, 'message': f'试卷已{"发布" if new_status == "available" else "关闭"}'})

@app.route('/api/exams/<int:exam_id>', methods=['DELETE'])
def api_delete_exam(exam_id):
    user = session.get('user')
    if not user or user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权限'}), 403
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '试卷不存在'}), 404
    if exam.creator_id != user.get('id'):
        return jsonify({'success': False, 'message': '只能删除自己创建的试卷'}), 403
    Submission.query.filter_by(exam_id=exam_id).delete()
    Draft.query.filter_by(exam_id=exam_id).delete()
    db.session.delete(exam)
    db.session.commit()
    return jsonify({'success': True, 'message': '试卷已删除'})

# ========== 杯赛相关 API ==========

@app.route('/api/contests/search')
@cache.cached(timeout=60, query_string=True)
def api_search_contests():
    keyword = request.args.get('q', '').strip().lower()
    query = Contest.query
    if keyword:
        query = query.filter(
            (Contest.name.contains(keyword)) |
            (Contest.organizer.contains(keyword)) |
            (Contest.description.contains(keyword))
        )
    # 普通用户只能看到已发布的杯赛，负责人和管理员可以看到自己的隐藏杯赛
    user = get_current_user()
    all_contests = query.all()
    filtered = []
    for c in all_contests:
        if c.visible:
            filtered.append(c)
        elif user:
            if user.get('role') == 'admin':
                filtered.append(c)
            elif ContestMembership.query.filter_by(user_id=user['id'], contest_id=c.id).first():
                filtered.append(c)
    contests = filtered
    data = [{
        'id': c.id,
        'name': c.name,
        'organizer': c.organizer,
        'description': c.description,
        'start_date': c.start_date,
        'end_date': c.end_date,
        'status': c.status,
        'participants': c.participants,
        'created_by': c.created_by
    } for c in contests]
    return jsonify({'success': True, 'data': data})

@app.route('/api/contests/<int:contest_id>/publish', methods=['PUT'])
@login_required
def api_publish_contest(contest_id):
    user = get_current_user()
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '无权操作'}), 403
    contest.visible = not contest.visible
    db.session.commit()
    return jsonify({'success': True, 'visible': contest.visible})

@app.route('/api/contests/<int:contest_id>/exams', methods=['GET'])
def api_contest_exams(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    exams = Exam.query.filter_by(contest_id=contest_id).all()
    user = get_current_user()
    is_owner = False
    if user:
        membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
        is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    data = []
    for e in exams:
        item = {
            'id': e.id,
            'title': e.title,
            'subject': e.subject or '',
            'total_score': e.total_score or 0,
            'status': e.status,
            'duration': e.duration,
            'created_at': e.created_at.strftime('%Y-%m-%d %H:%M'),
            'submission_count': Submission.query.filter_by(exam_id=e.id).count() if is_owner else None
        }
        data.append(item)
    return jsonify({'success': True, 'exams': data})

@app.route('/api/contests/<int:contest_id>/import-exam', methods=['POST'])
@login_required
def api_import_exam_to_contest(contest_id):
    """将已有考试导入到杯赛"""
    user = get_current_user()
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '无权操作'}), 403
    data = request.get_json(force=True, silent=True)
    exam_id = data.get('exam_id')
    if not exam_id:
        return jsonify({'success': False, 'message': '请选择考试'}), 400
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '考试不存在'}), 404
    if exam.contest_id and exam.contest_id != contest_id:
        return jsonify({'success': False, 'message': '该考试已关联到其他杯赛'}), 400
    exam.contest_id = contest_id
    db.session.commit()
    return jsonify({'success': True, 'message': '导入成功'})

@app.route('/api/contests/<int:contest_id>/remove-exam', methods=['POST'])
@login_required
def api_remove_exam_from_contest(contest_id):
    """将考试从杯赛移除（不删除考试）"""
    user = get_current_user()
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '无权操作'}), 403
    data = request.get_json(force=True, silent=True)
    exam_id = data.get('exam_id')
    exam = Exam.query.get(exam_id)
    if not exam or exam.contest_id != contest_id:
        return jsonify({'success': False, 'message': '考试不存在或不属于该杯赛'}), 404
    exam.contest_id = None
    db.session.commit()
    return jsonify({'success': True, 'message': '已移除'})

@app.route('/api/contests/<int:contest_id>/available-exams', methods=['GET'])
@login_required
def api_available_exams_for_contest(contest_id):
    """获取可导入到杯赛的考试列表（当前用户创建的、未关联杯赛的考试）"""
    user = get_current_user()
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
    if not is_owner:
        return jsonify({'success': False, 'message': '无权操作'}), 403
    # 查找未关联杯赛的考试（负责人自己创建的，或管理员可看所有）
    query = Exam.query.filter((Exam.contest_id == None) | (Exam.contest_id == 0))
    if user.get('role') != 'admin':
        query = query.filter_by(creator_id=user['id'])
    exams = query.all()
    data = [{
        'id': e.id,
        'title': e.title,
        'subject': e.subject or '',
        'total_score': e.total_score or 0,
        'status': e.status,
        'created_at': e.created_at.strftime('%Y-%m-%d %H:%M')
    } for e in exams]
    return jsonify({'success': True, 'exams': data})

@app.route('/api/contests/<int:contest_id>/leaderboard', methods=['GET'])
def api_contest_leaderboard(contest_id):
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    exam_ids = [e.id for e in Exam.query.filter_by(contest_id=contest_id).all()]
    if not exam_ids:
        return jsonify({'success': True, 'leaderboard': []})
    from sqlalchemy import func
    results = db.session.query(
        Submission.user_id,
        func.sum(Submission.score).label('total_score'),
        func.count(Submission.id).label('exam_count')
    ).filter(
        Submission.exam_id.in_(exam_ids),
        Submission.graded == True
    ).group_by(Submission.user_id).order_by(func.sum(Submission.score).desc()).limit(50).all()
    leaderboard = []
    for rank, (user_id, total_score, exam_count) in enumerate(results, 1):
        u = User.query.get(user_id)
        leaderboard.append({
            'rank': rank,
            'user_name': u.name if u else '未知用户',
            'total_score': total_score or 0,
            'exam_count': exam_count
        })
    return jsonify({'success': True, 'leaderboard': leaderboard})

@app.route('/api/contests', methods=['POST'])
def api_create_contest():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    data = request.get_json(force=True, silent=True)
    contest = Contest(
        name=data.get('name'),
        organizer=data.get('organizer'),
        description=data.get('description'),
        start_date=data.get('start_date'),
        end_date=data.get('end_date'),
        status=data.get('status', 'upcoming'),
        participants=data.get('participants', 0),
        created_by=user.get('id')
    )
    db.session.add(contest)
    db.session.commit()
    return jsonify({'success': True, 'message': '杯赛创建成功', 'contest_id': contest.id})

# ========== 题库 API ==========

@app.route('/api/contests/<int:contest_id>/question-bank', methods=['GET'])
@login_required
def api_get_question_bank(contest_id):
    """获取杯赛题库列表"""
    user = session['user']
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    if contest.status == 'abolished':
        return jsonify({'success': False, 'message': '杯赛已废止'}), 400
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    if not membership and user.get('role') != 'admin':
        return jsonify({'success': False, 'message': '无权限访问题库'}), 403
    items = QuestionBankItem.query.filter_by(contest_id=contest_id).order_by(QuestionBankItem.created_at.desc()).all()
    data = []
    for item in items:
        contributor = User.query.get(item.contributor_id)
        data.append({
            'id': item.id,
            'type': item.type,
            'content': item.content,
            'options': json.loads(item.options) if item.options else [],
            'answer': item.answer,
            'score': item.score,
            'contributor_id': item.contributor_id,
            'contributor_name': contributor.name if contributor else '未知',
            'created_at': item.created_at.strftime('%Y-%m-%d %H:%M')
        })
    return jsonify({'success': True, 'questions': data})

@app.route('/api/contests/<int:contest_id>/question-bank', methods=['POST'])
@login_required
def api_add_question_bank(contest_id):
    """添加题目到题库（杯赛老师和负责人）"""
    user = session['user']
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    if contest.status == 'abolished':
        return jsonify({'success': False, 'message': '杯赛已废止'}), 400
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    if not membership and user.get('role') != 'admin':
        return jsonify({'success': False, 'message': '无权限'}), 403
    data = request.get_json(force=True, silent=True)
    qtype = data.get('type', '')
    content = data.get('content', '')
    if not qtype or not content:
        return jsonify({'success': False, 'message': '题目类型和内容不能为空'}), 400
    item = QuestionBankItem(
        contest_id=contest_id,
        contributor_id=user['id'],
        type=qtype,
        content=content,
        options=json.dumps(data.get('options', [])),
        answer=data.get('answer', ''),
        score=data.get('score', 10)
    )
    db.session.add(item)
    db.session.commit()
    return jsonify({'success': True, 'message': '题目已添加', 'id': item.id})

@app.route('/api/contests/<int:contest_id>/question-bank/<int:qid>', methods=['DELETE'])
@login_required
def api_delete_question_bank(contest_id, qid):
    """删除题库题目（负责人可删除任何题目，老师只能删除自己贡献的题目）"""
    user = session['user']
    item = QuestionBankItem.query.get(qid)
    if not item or item.contest_id != contest_id:
        return jsonify({'success': False, 'message': '题目不存在'}), 404
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first()
    is_owner = membership is not None
    is_contributor = item.contributor_id == user['id']
    is_admin = user.get('role') == 'admin'
    if not is_owner and not is_contributor and not is_admin:
        return jsonify({'success': False, 'message': '无权限删除'}), 403
    db.session.delete(item)
    db.session.commit()
    return jsonify({'success': True, 'message': '题目已删除'})

@app.route('/api/contests/<int:contest_id>/create-exam-from-bank', methods=['POST'])
@login_required
def api_create_exam_from_bank(contest_id):
    """负责人从题库选题创建考试"""
    user = session['user']
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    if contest.status == 'abolished':
        return jsonify({'success': False, 'message': '杯赛已废止'}), 400
    membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first()
    if not membership and user.get('role') != 'admin':
        return jsonify({'success': False, 'message': '只有杯赛负责人才能组卷'}), 403
    data = request.get_json(force=True, silent=True)
    title = data.get('title', '')
    duration = data.get('duration', 120)
    question_ids = data.get('question_ids', [])
    scheduled_start = data.get('scheduled_start', '')
    scheduled_end = data.get('scheduled_end', '')
    score_release_time = data.get('score_release_time', '')
    if not title or not question_ids:
        return jsonify({'success': False, 'message': '请填写标题并选择题目'}), 400
    # 从题库获取题目并转换为考试题目格式
    questions = []
    for qid in question_ids:
        item = QuestionBankItem.query.get(qid)
        if not item or item.contest_id != contest_id:
            continue
        q = {
            'type': item.type,
            'content': item.content,
            'score': item.score,
            'answer': item.answer or ''
        }
        if item.type == 'choice':
            q['options'] = json.loads(item.options) if item.options else []
        questions.append(q)
    if not questions:
        return jsonify({'success': False, 'message': '未找到有效题目'}), 400
    total_score = sum(q.get('score', 0) for q in questions)
    exam = Exam(
        title=title,
        duration=duration,
        total_score=total_score,
        creator_id=user['id'],
        contest_id=contest_id
    )
    if scheduled_start:
        try:
            exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M')
        except ValueError:
            pass
    if scheduled_end:
        try:
            exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M')
        except ValueError:
            pass
    if score_release_time:
        try:
            release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M')
            if not exam.scheduled_end or release_dt > exam.scheduled_end:
                exam.score_release_time = release_dt
        except ValueError:
            pass
    # 加密存储试卷内容
    questions_json = json.dumps(questions)
    exam.encrypted_questions = encrypt_questions(questions_json)
    exam.is_encrypted = True
    exam.set_questions(questions)  # 同时保留明文用于兼容
    db.session.add(exam)
    db.session.commit()
    # 通知杯赛已报名用户
    registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
    for reg in registrations:
        if reg.user_id != user['id']:
            add_notification(reg.user_id, 'contest_new_exam',
                f'您报名的杯赛「{contest.name}」发布了新考试「{title}」，快去查看吧！',
                from_user=user.get('name', ''), post_id=exam.id)
    return jsonify({'success': True, 'message': '考试创建成功', 'exam_id': exam.id})

# ========== 杯赛报名 API ==========

@app.route('/api/contests/<int:contest_id>/register', methods=['POST'])
@login_required
def api_register_contest(contest_id):
    """用户报名杯赛"""
    user = session['user']
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    if contest.status == 'abolished':
        return jsonify({'success': False, 'message': '杯赛已废止，无法报名'}), 400

    existing = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    if existing:
        return jsonify({'success': False, 'message': '您已报名该杯赛'}), 400

    registration = ContestRegistration(user_id=user['id'], contest_id=contest_id)
    db.session.add(registration)
    contest.participants += 1
    # 自动加入杯赛讨论群
    chatroom = ChatRoom.query.filter_by(contest_id=contest_id).first()
    if chatroom:
        existing_member = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first()
        if not existing_member:
            db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member'))
    db.session.commit()

    return jsonify({'success': True, 'message': '报名成功', 'participants': contest.participants})

@app.route('/api/contests/<int:contest_id>/unregister', methods=['POST'])
@login_required
def api_unregister_contest(contest_id):
    """用户取消报名"""
    user = session['user']
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404

    registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
    if not registration:
        return jsonify({'success': False, 'message': '您尚未报名该杯赛'}), 400

    db.session.delete(registration)
    if contest.participants > 0:
        contest.participants -= 1
    db.session.commit()

    return jsonify({'success': True, 'message': '已取消报名', 'participants': contest.participants})

# ========== 杯赛讨论区 API ==========

@app.route('/api/contests/<int:contest_id>/posts', methods=['GET'])
def api_contest_posts(contest_id):
    """获取杯赛讨论区帖子列表"""
    contest = Contest.query.get_or_404(contest_id)
    # 置顶帖子在前，然后按创建时间倒序
    posts = Post.query.filter_by(contest_id=contest_id).order_by(Post.pinned.desc(), Post.created_at.desc()).all()
    user = get_current_user()
    uid = user['id'] if user else None
    data = []
    for p in posts:
        p_dict = {
            'id': p.id,
            'title': p.title,
            'content': p.content[:200] + ('...' if len(p.content) > 200 else ''),
            'author': p.author.name,
            'author_id': p.author_id,
            'tag': p.tag,
            'is_official': p.is_official,
            'pinned': p.pinned,
            'likes': p.likes,
            'replies': p.replies_count,
            'views': p.views,
            'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
        }
        if uid:
            p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None
        data.append(p_dict)
    return jsonify({'success': True, 'data': data})

@app.route('/api/contests/<int:contest_id>/posts', methods=['POST'])
@login_required
def api_create_contest_post(contest_id):
    """在杯赛讨论区发布帖子"""
    user = session['user']
    contest = Contest.query.get_or_404(contest_id)

    # 权限检查
    if not can_post_in_contest(user, contest):
        return jsonify({'success': False, 'message': '您没有权限在此杯赛讨论区发帖'}), 403

    data = request.get_json(force=True, silent=True)
    title = data.get('title', '').strip()
    content = data.get('content', '').strip()
    if not title or not content:
        return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
    if len(title) > 100:
        return jsonify({'success': False, 'message': '标题不能超过100字'}), 400

    post = Post(
        title=title,
        content=content,
        author_id=user['id'],
        tag='杯赛讨论',  # 可自定义
        contest_id=contest_id,
        is_official=(user['role'] in ['admin', 'teacher'] and data.get('is_official', False))
    )
    db.session.add(post)
    db.session.commit()
    return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})

# ========== 论坛相关 API ==========

@app.route('/api/posts/search')
@cache.cached(timeout=30, query_string=True)
def api_search_posts():
    keyword = request.args.get('q', '').strip().lower()
    tag = request.args.get('tag', '').strip()
    sort = request.args.get('sort', 'newest')

    # 只获取非杯赛的帖子（contest_id 为 NULL）
    query = Post.query.filter_by(contest_id=None)

    if keyword:
        query = query.filter(
            (Post.title.contains(keyword)) | (Post.content.contains(keyword))
        )
    if tag:
        query = query.filter_by(tag=tag)

    if sort == 'hottest':
        query = query.order_by(Post.pinned.desc(), Post.likes.desc())
    elif sort == 'most_replies':
        query = query.order_by(Post.pinned.desc(), Post.replies_count.desc())
    else:
        query = query.order_by(Post.pinned.desc(), Post.created_at.desc())

    posts = query.all()
    user = session.get('user')
    uid = user.get('id') if user else None
    data = []
    for p in posts:
        p_dict = {
            'id': p.id,
            'title': p.title,
            'content': p.content[:200] + ('...' if len(p.content) > 200 else ''),
            'author': p.author.name,
            'author_id': p.author_id,
            'tag': p.tag,
            'is_official': p.is_official,
            'pinned': p.pinned,
            'likes': p.likes,
            'replies': p.replies_count,
            'views': p.views,
            'has_poll': p.has_poll,
            'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'),
        }
        if uid:
            p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None
            p_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=p.id).first() is not None
        data.append(p_dict)
    return jsonify({'success': True, 'data': data})

@app.route('/api/posts', methods=['POST'])
def api_create_post():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    data = request.get_json(force=True, silent=True)
    title = data.get('title', '').strip()
    content = data.get('content', '').strip()
    tag = data.get('tag', '全部')
    if not title or not content:
        return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
    if len(title) > 100:
        return jsonify({'success': False, 'message': '标题不能超过100字'}), 400
    post = Post(
        title=title,
        content=content,
        author_id=user.get('id'),
        tag=tag,
        is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False))
    )
    db.session.add(post)
    db.session.commit()
    return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})

@app.route('/api/posts/<int:post_id>')
def api_get_post(post_id):
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    post.views += 1
    db.session.commit()

    replies = Reply.query.filter_by(post_id=post_id).order_by(Reply.created_at).all()
    user = session.get('user')
    uid = user.get('id') if user else None
    post_dict = {
        'id': post.id,
        'title': post.title,
        'content': post.content,
        'author': post.author.name,
        'author_id': post.author_id,
        'tag': post.tag,
        'is_official': post.is_official,
        'pinned': post.pinned,
        'likes': post.likes,
        'replies': post.replies_count,
        'views': post.views,
        'has_poll': post.has_poll,
        'created_at': post.created_at.strftime('%Y-%m-%d %H:%M'),
        'edited': (post.updated_at > post.created_at),
    }
    if uid:
        post_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=post.id).first() is not None
        post_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=post.id).first() is not None
    replies_data = []
    for r in replies:
        r_dict = {
            'id': r.id,
            'content': r.content,
            'author': r.author.name,
            'author_id': r.author_id,
            'likes': r.likes,
            'reply_to': r.reply_to,
            'created_at': r.created_at.strftime('%Y-%m-%d %H:%M'),
            'edited': (r.updated_at > r.created_at),
        }
        if uid:
            r_dict['liked'] = Reaction.query.filter_by(user_id=uid, reply_id=r.id).first() is not None
        replies_data.append(r_dict)
    return jsonify({'success': True, 'data': post_dict, 'replies': replies_data})

@app.route('/api/posts/<int:post_id>', methods=['DELETE'])
def api_delete_post(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权删除'}), 403
    db.session.delete(post)
    db.session.commit()
    return jsonify({'success': True, 'message': '帖子已删除'})

@app.route('/api/posts/<int:post_id>/like', methods=['POST'])
def api_like_post(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    uid = user.get('id')
    reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first()
    if reaction:
        db.session.delete(reaction)
        post.likes -= 1
        db.session.commit()
        return jsonify({'success': True, 'liked': False, 'likes': post.likes})
    else:
        reaction = Reaction(user_id=uid, post_id=post_id, reaction='like')
        db.session.add(reaction)
        post.likes += 1
        db.session.commit()
        return jsonify({'success': True, 'liked': True, 'likes': post.likes})

@app.route('/api/posts/<int:post_id>/bookmark', methods=['POST'])
def api_bookmark_post(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    uid = user.get('id')
    bookmark = Bookmark.query.filter_by(user_id=uid, post_id=post_id).first()
    if bookmark:
        db.session.delete(bookmark)
        db.session.commit()
        return jsonify({'success': True, 'bookmarked': False})
    else:
        bookmark = Bookmark(user_id=uid, post_id=post_id)
        db.session.add(bookmark)
        db.session.commit()
        return jsonify({'success': True, 'bookmarked': True})

@app.route('/api/posts/<int:post_id>/pin', methods=['POST'])
def api_pin_post(post_id):
    user = session.get('user')
    if not user or user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权限'}), 403
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    post.pinned = not post.pinned
    db.session.commit()
    return jsonify({'success': True, 'pinned': post.pinned})

@app.route('/api/posts/<int:post_id>/replies', methods=['POST'])
def api_create_reply(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    data = request.get_json(force=True, silent=True)
    content = data.get('content', '').strip()
    if not content:
        return jsonify({'success': False, 'message': '回复内容不能为空'}), 400
    reply = Reply(
        post_id=post_id,
        author_id=user.get('id'),
        content=content,
        reply_to=data.get('reply_to', '')
    )
    db.session.add(reply)
    post.replies_count += 1
    db.session.commit()
    return jsonify({'success': True, 'message': '回复成功', 'reply': {
        'id': reply.id,
        'content': reply.content,
        'author': user.get('name'),
        'author_id': user.get('id'),
        'likes': 0,
        'reply_to': reply.reply_to,
        'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M')
    }})

@app.route('/api/replies/<int:reply_id>/like', methods=['POST'])
def api_like_reply(reply_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    reply = Reply.query.get(reply_id)
    if not reply:
        return jsonify({'success': False, 'message': '回复不存在'}), 404
    uid = user.get('id')
    reaction = Reaction.query.filter_by(user_id=uid, reply_id=reply_id).first()
    if reaction:
        db.session.delete(reaction)
        reply.likes -= 1
        db.session.commit()
        return jsonify({'success': True, 'liked': False, 'likes': reply.likes})
    else:
        reaction = Reaction(user_id=uid, reply_id=reply_id, reaction='like')
        db.session.add(reaction)
        reply.likes += 1
        db.session.commit()
        return jsonify({'success': True, 'liked': True, 'likes': reply.likes})

@app.route('/api/replies/<int:reply_id>', methods=['DELETE'])
def api_delete_reply(reply_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    reply = Reply.query.get(reply_id)
    if not reply:
        return jsonify({'success': False, 'message': '回复不存在'}), 404
    if reply.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权删除'}), 403
    post = Post.query.get(reply.post_id)
    if post:
        post.replies_count -= 1
    db.session.delete(reply)
    db.session.commit()
    return jsonify({'success': True, 'message': '回复已删除'})

@app.route('/api/user/bookmarks')
def api_user_bookmarks():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    uid = user.get('id')
    bookmarks = Bookmark.query.filter_by(user_id=uid).all()
    post_ids = [b.post_id for b in bookmarks]
    posts = Post.query.filter(Post.id.in_(post_ids)).all()
    data = [{
        'id': p.id,
        'title': p.title,
        'author': p.author.name,
        'created_at': p.created_at.strftime('%Y-%m-%d %H:%M')
    } for p in posts]
    return jsonify({'success': True, 'data': data})

# ========== 论坛增强 API ==========

@app.route('/api/posts/<int:post_id>/edit', methods=['POST'])
def api_edit_post(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'):
        return jsonify({'success': False, 'message': '无权编辑'}), 403
    data = request.get_json(force=True, silent=True)
    new_title = data.get('title', '').strip()
    new_content = data.get('content', '').strip()
    new_tag = data.get('tag', post.tag)
    if not new_title or not new_content:
        return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
    post.title = new_title
    post.content = new_content
    post.tag = new_tag
    db.session.commit()
    return jsonify({'success': True, 'message': '编辑成功'})

@app.route('/api/replies/<int:reply_id>/edit', methods=['POST'])
def api_edit_reply(reply_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    reply = Reply.query.get(reply_id)
    if not reply:
        return jsonify({'success': False, 'message': '回复不存在'}), 404
    if reply.author_id != user.get('id'):
        return jsonify({'success': False, 'message': '无权编辑'}), 403
    data = request.get_json(force=True, silent=True)
    new_content = data.get('content', '').strip()
    if not new_content:
        return jsonify({'success': False, 'message': '内容不能为空'}), 400
    reply.content = new_content
    db.session.commit()
    return jsonify({'success': True, 'message': '编辑成功'})

@app.route('/api/posts/<int:post_id>/poll', methods=['POST'])
def api_create_poll(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    if post.author_id != user.get('id'):
        return jsonify({'success': False, 'message': '只有作者可以创建投票'}), 403
    if post.poll:
        return jsonify({'success': False, 'message': '该帖子已有投票'}), 400
    data = request.get_json(force=True, silent=True)
    question = data.get('question', '').strip()
    options = data.get('options', [])
    multi = data.get('multi', False)
    if not question or len(options) < 2:
        return jsonify({'success': False, 'message': '请填写问题和至少2个选项'}), 400
    poll = Poll(
        post_id=post_id,
        question=question,
        multi=multi
    )
    poll.set_options([{'text': o, 'votes': 0} for o in options])
    poll.set_voters({})
    db.session.add(poll)
    post.has_poll = True
    db.session.commit()
    return jsonify({'success': True, 'message': '投票创建成功'})

@app.route('/api/posts/<int:post_id>/vote', methods=['POST'])
def api_vote_poll(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    poll = Poll.query.filter_by(post_id=post_id).first()
    if not poll:
        return jsonify({'success': False, 'message': '投票不存在'}), 404
    uid = user.get('id')
    voters = poll.get_voters()
    if str(uid) in voters:
        return jsonify({'success': False, 'message': '您已投过票'}), 400
    data = request.get_json(force=True, silent=True)
    choices = data.get('choices', [])
    if not choices:
        return jsonify({'success': False, 'message': '请选择选项'}), 400
    if not poll.multi and len(choices) > 1:
        return jsonify({'success': False, 'message': '该投票为单选'}), 400
    options = poll.get_options()
    for idx in choices:
        if 0 <= idx < len(options):
            options[idx]['votes'] += 1
    voters[str(uid)] = choices
    poll.set_options(options)
    poll.set_voters(voters)
    poll.total_votes += 1
    db.session.commit()
    return jsonify({'success': True, 'message': '投票成功', 'poll': {
        'options': options,
        'total_votes': poll.total_votes
    }})

@app.route('/api/posts/<int:post_id>/poll')
def api_get_poll(post_id):
    poll = Poll.query.filter_by(post_id=post_id).first()
    if not poll:
        return jsonify({'success': False, 'message': '投票不存在'}), 404
    user = session.get('user')
    voted = False
    my_choices = []
    if user:
        voters = poll.get_voters()
        if str(user.get('id')) in voters:
            voted = True
            my_choices = voters[str(user.get('id'))]
    return jsonify({
        'success': True,
        'poll': {
            'question': poll.question,
            'options': poll.get_options(),
            'total_votes': poll.total_votes,
            'multi': poll.multi
        },
        'voted': voted,
        'my_choices': my_choices
    })

@app.route('/api/report', methods=['POST'])
def api_report():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    data = request.get_json(force=True, silent=True)
    rtype = data.get('type', '')
    target_id = data.get('target_id', 0)
    reason = data.get('reason', '')
    detail = data.get('detail', '')
    if not rtype or not target_id or not reason:
        return jsonify({'success': False, 'message': '请填写举报信息'}), 400
    report = Report(
        type=rtype,
        target_id=target_id,
        reporter_id=user.get('id'),
        reason=reason,
        detail=detail
    )
    db.session.add(report)
    db.session.commit()
    return jsonify({'success': True, 'message': '举报已提交，管理员将尽快处理'})

@app.route('/api/posts/<int:post_id>/react', methods=['POST'])
def api_react_post(post_id):
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    data = request.get_json(force=True, silent=True)
    reaction_type = data.get('reaction', '')
    if reaction_type not in ('like', 'love', 'haha', 'wow', 'sad', 'angry'):
        return jsonify({'success': False, 'message': '无效反应'}), 400
    uid = user.get('id')
    reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first()
    if reaction:
        if reaction.reaction == reaction_type:
            db.session.delete(reaction)
        else:
            reaction.reaction = reaction_type
    else:
        reaction = Reaction(user_id=uid, post_id=post_id, reaction=reaction_type)
        db.session.add(reaction)
    db.session.commit()
    counts = {}
    for r in Reaction.query.filter_by(post_id=post_id).all():
        counts[r.reaction] = counts.get(r.reaction, 0) + 1
    return jsonify({'success': True, 'reactions': counts})

@app.route('/api/user/profile/<user_id>')
def api_user_profile(user_id):
    user = User.query.get(int(user_id)) if user_id.isdigit() else None
    if not user:
        return jsonify({'success': False, 'message': '用户不存在'}), 404
    post_count = Post.query.filter_by(author_id=user.id).count()
    reply_count = Reply.query.filter_by(author_id=user.id).count()
    likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0
    points = post_count * 10 + reply_count * 3 + likes_received * 2
    level = calc_level(points)
    recent_posts = Post.query.filter_by(author_id=user.id).order_by(Post.created_at.desc()).limit(5).all()
    recent_posts_data = [{'id': p.id, 'title': p.title} for p in recent_posts]
    badges = []
    if post_count >= 1: badges.append({'icon': '✍️', 'name': '初次发帖', 'desc': '发布第一篇帖子'})
    if post_count >= 10: badges.append({'icon': '📝', 'name': '笔耕不辍', 'desc': '发布10篇帖子'})
    if post_count >= 50: badges.append({'icon': '📚', 'name': '著作等身', 'desc': '发布50篇帖子'})
    if reply_count >= 10: badges.append({'icon': '💬', 'name': '热心回复', 'desc': '回复10次'})
    if reply_count >= 100: badges.append({'icon': '🗣️', 'name': '话题达人', 'desc': '回复100次'})
    if likes_received >= 10: badges.append({'icon': '👍', 'name': '小有人气', 'desc': '获得10个赞'})
    if likes_received >= 100: badges.append({'icon': '🌟', 'name': '人气之星', 'desc': '获得100个赞'})
    if likes_received >= 500: badges.append({'icon': '👑', 'name': '万人迷', 'desc': '获得500个赞'})
    return jsonify({
        'success': True,
        'profile': {
            'user_id': user.id,
            'name': user.name,
            'points': points,
            'level': level,
            'level_title': LEVEL_TITLES.get(level, ''),
            'posts_count': post_count,
            'replies_count': reply_count,
            'likes_received': likes_received,
            'badges': badges,
            'recent_posts': recent_posts_data
        }
    })

@app.route('/api/forum/hot')
@cache.cached(timeout=120)
def api_hot_posts():
    posts = Post.query.order_by((Post.likes*3 + Post.replies_count*2 + Post.views).desc()).limit(10).all()
    data = [{'id': p.id, 'title': p.title, 'likes': p.likes, 'replies': p.replies_count} for p in posts]
    return jsonify({'success': True, 'data': data})

@app.route('/api/forum/stats')
@cache.cached(timeout=60)
def api_forum_stats():
    total_posts = Post.query.count()
    total_replies = Reply.query.count()
    total_users = User.query.count()
    today = datetime.utcnow().date()
    today_posts = Post.query.filter(db.func.date(Post.created_at) == today).count()
    today_replies = Reply.query.filter(db.func.date(Reply.created_at) == today).count()
    online_count = get_online_count()
    tag_counts = {}
    for tag in db.session.query(Post.tag, db.func.count(Post.tag)).group_by(Post.tag).all():
        tag_counts[tag[0]] = tag[1]
    active_users = []
    recent_posts_users = Post.query.order_by(Post.created_at.desc()).limit(10).all()
    seen = set()
    for p in recent_posts_users:
        if p.author_id not in seen and len(active_users) < 5:
            seen.add(p.author_id)
            points = (Post.query.filter_by(author_id=p.author_id).count()*10 + 
                     Reply.query.filter_by(author_id=p.author_id).count()*3)
            level = calc_level(points)
            active_users.append({
                'user_id': p.author_id,
                'name': p.author.name,
                'level': level,
                'level_title': LEVEL_TITLES.get(level, '')
            })
    return jsonify({
        'success': True,
        'stats': {
            'total_posts': total_posts,
            'total_replies': total_replies,
            'total_users': total_users,
            'today_posts': today_posts,
            'today_replies': today_replies,
            'online_count': online_count,
            'tag_counts': tag_counts,
            'active_users': active_users
        }
    })

@app.route('/api/forum/leaderboard')
@cache.cached(timeout=300)
def api_leaderboard():
    users = User.query.all()
    board = []
    for u in users:
        post_count = Post.query.filter_by(author_id=u.id).count()
        reply_count = Reply.query.filter_by(author_id=u.id).count()
        likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=u.id).scalar() or 0
        points = post_count * 10 + reply_count * 3 + likes_received * 2
        level = calc_level(points)
        board.append({
            'user_id': u.id,
            'name': u.name,
            'points': points,
            'level': level,
            'level_title': LEVEL_TITLES.get(level, ''),
            'posts_count': post_count,
            'likes_received': likes_received
        })
    board.sort(key=lambda x: x['points'], reverse=True)
    return jsonify({'success': True, 'data': board[:20]})

@app.route('/api/posts/with-poll', methods=['POST'])
def api_create_post_with_poll():
    user = session.get('user')
    if not user:
        return jsonify({'success': False, 'message': '请先登录'}), 401
    data = request.get_json(force=True, silent=True)
    title = data.get('title', '').strip()
    content = data.get('content', '').strip()
    tag = data.get('tag', '全部')
    if not title or not content:
        return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400
    post = Post(
        title=title,
        content=content,
        author_id=user.get('id'),
        tag=tag,
        is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False))
    )
    db.session.add(post)
    db.session.flush()
    poll_data = data.get('poll')
    if poll_data and poll_data.get('question') and len(poll_data.get('options', [])) >= 2:
        poll = Poll(
            post_id=post.id,
            question=poll_data['question'],
            multi=poll_data.get('multi', False)
        )
        poll.set_options([{'text': o, 'votes': 0} for o in poll_data['options']])
        poll.set_voters({})
        db.session.add(poll)
        post.has_poll = True
    db.session.commit()
    return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id})

@app.route('/api/posts/<int:post_id>/share', methods=['POST'])
def api_share_post(post_id):
    return jsonify({'success': True})

# ========== 聊天系统页面 ==========
@app.route('/chat')
@login_required
def chat_page():
    return render_template('chat.html')

# ========== 聊天系统 REST API ==========

@app.route('/api/chat/rooms')
@login_required
def api_chat_rooms():
    """获取当前用户所有聊天室（含最后一条消息、未读数）"""
    user_id = session['user']['id']
    memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
    rooms_data = []
    for m in memberships:
        room = m.room
        last_msg = Message.query.filter_by(room_id=room.id).order_by(Message.created_at.desc()).first()
        unread = Message.query.filter(
            Message.room_id == room.id,
            Message.created_at > m.last_read_at,
            Message.sender_id != user_id
        ).count()
        # 私聊显示对方名字和头像
        room_name = room.name
        room_avatar = room.avatar
        if room.type == 'private':
            other_member = ChatRoomMember.query.filter(
                ChatRoomMember.room_id == room.id,
                ChatRoomMember.user_id != user_id
            ).first()
            if other_member:
                other_user = User.query.get(other_member.user_id)
                if other_user:
                    room_name = other_user.name
                    room_avatar = other_user.avatar or ''
        rooms_data.append({
            'id': room.id,
            'type': room.type,
            'name': room_name or '未命名群聊',
            'avatar': room_avatar or '',
            'muted': m.muted,
            'unread': unread,
            'last_message': {
                'content': ('撤回了一条消息' if last_msg.recalled else (last_msg.content[:50] if last_msg.content else '[文件]')) if last_msg else '',
                'sender_name': User.query.get(last_msg.sender_id).name if last_msg else '',
                'created_at': last_msg.created_at.strftime('%Y-%m-%d %H:%M') if last_msg else '',
                'type': last_msg.type if last_msg else 'text'
            } if last_msg else None,
            'member_count': ChatRoomMember.query.filter_by(room_id=room.id).count()
        })
    rooms_data.sort(key=lambda r: r['last_message']['created_at'] if r['last_message'] else '', reverse=True)
    return jsonify({'success': True, 'rooms': rooms_data})

@app.route('/api/chat/rooms', methods=['POST'])
@login_required
def api_create_chat_room():
    """创建群聊"""
    user_id = session['user']['id']
    data = request.get_json(force=True, silent=True)
    name = data.get('name', '').strip()
    member_ids = data.get('member_ids', [])
    if not name:
        return jsonify({'success': False, 'message': '请输入群名称'}), 400
    room = ChatRoom(type='group', name=name, creator_id=user_id)
    db.session.add(room)
    db.session.flush()
    # 创建者为管理员
    db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='admin'))
    # 添加成员
    for mid in member_ids:
        if mid != user_id:
            db.session.add(ChatRoomMember(room_id=room.id, user_id=mid, role='member'))
    # 系统消息
    db.session.add(Message(room_id=room.id, sender_id=user_id, type='system', content=f'{session["user"]["name"]} 创建了群聊'))
    db.session.commit()
    return jsonify({'success': True, 'room_id': room.id})

@app.route('/api/chat/rooms/<int:room_id>', methods=['PUT'])
@login_required
def api_update_chat_room(room_id):
    """修改群名/群头像（仅管理员）"""
    user_id = session['user']['id']
    room = ChatRoom.query.get_or_404(room_id)
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member or member.role != 'admin':
        return jsonify({'success': False, 'message': '无权限'}), 403
    data = request.get_json(force=True, silent=True)
    if 'name' in data:
        room.name = data['name']
    if 'avatar' in data:
        room.avatar = data['avatar']
    db.session.commit()
    socketio.emit('room_updated', {'room_id': room_id, 'name': room.name, 'avatar': room.avatar}, room=f'room_{room_id}')
    return jsonify({'success': True})

@app.route('/api/chat/private/<int:target_user_id>', methods=['POST'])
@login_required
def api_get_or_create_private_chat(target_user_id):
    """获取或创建与某好友的私聊室"""
    user_id = session['user']['id']
    if target_user_id == user_id:
        return jsonify({'success': False, 'message': '不能和自己聊天'}), 400
    # 查找已有私聊
    my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=user_id).subquery()
    target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=target_user_id).subquery()
    existing = ChatRoom.query.filter(
        ChatRoom.type == 'private',
        ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)),
        ChatRoom.id.in_(db.session.query(target_rooms.c.room_id))
    ).first()
    if existing:
        return jsonify({'success': True, 'room_id': existing.id})
    # 创建新私聊
    room = ChatRoom(type='private', creator_id=user_id)
    db.session.add(room)
    db.session.flush()
    db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='member'))
    db.session.add(ChatRoomMember(room_id=room.id, user_id=target_user_id, role='member'))
    db.session.commit()
    return jsonify({'success': True, 'room_id': room.id})

@app.route('/api/chat/rooms/<int:room_id>/messages')
@login_required
def api_chat_messages(room_id):
    """分页获取历史消息"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    before_id = request.args.get('before_id', type=int)
    limit = request.args.get('limit', 30, type=int)
    query = Message.query.filter_by(room_id=room_id)
    if before_id:
        query = query.filter(Message.id < before_id)
    messages = query.order_by(Message.created_at.desc()).limit(limit).all()
    messages.reverse()
    data = []
    for msg in messages:
        sender = User.query.get(msg.sender_id)
        msg_data = {
            'id': msg.id,
            'room_id': msg.room_id,
            'sender_id': msg.sender_id,
            'sender_name': sender.name if sender else '未知用户',
            'sender_avatar': (sender.avatar or '') if sender else '',
            'type': msg.type,
            'content': msg.content,
            'file_url': msg.file_url,
            'file_name': msg.file_name,
            'recalled': msg.recalled,
            'reply_to': None,
            'mentions': msg.mentions or '',
            'reactions': _get_reactions_summary(msg.id) if not msg.recalled else {},
            'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
        }
        if msg.reply_to_id:
            reply_msg = Message.query.get(msg.reply_to_id)
            if reply_msg:
                reply_sender = User.query.get(reply_msg.sender_id)
                msg_data['reply_to'] = {
                    'id': reply_msg.id,
                    'sender_name': reply_sender.name if reply_sender else '未知',
                    'content': reply_msg.content[:50] if reply_msg.content else '[文件]'
                }
        data.append(msg_data)
    return jsonify({'success': True, 'messages': data})

@app.route('/api/chat/rooms/<int:room_id>/members')
@login_required
def api_chat_room_members(room_id):
    """获取聊天室成员列表"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    room = ChatRoom.query.get_or_404(room_id)
    members = ChatRoomMember.query.filter_by(room_id=room_id).all()
    data = []
    for m in members:
        u = User.query.get(m.user_id)
        if u:
            data.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'role': m.role,
                         'nickname': m.nickname or '', 'muted': m.muted,
                         'is_creator': u.id == room.creator_id,
                         'joined_at': m.joined_at.strftime('%Y-%m-%d %H:%M')})
    return jsonify({'success': True, 'members': data, 'creator_id': room.creator_id})

@app.route('/api/chat/rooms/<int:room_id>/members', methods=['POST'])
@login_required
def api_invite_chat_members(room_id):
    """邀请成员入群"""
    user_id = session['user']['id']
    room = ChatRoom.query.get_or_404(room_id)
    if room.type == 'private':
        return jsonify({'success': False, 'message': '私聊不能邀请成员'}), 400
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    data = request.get_json(force=True, silent=True)
    user_ids = data.get('user_ids', [])
    added = []
    for uid in user_ids:
        if not ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first():
            db.session.add(ChatRoomMember(room_id=room_id, user_id=uid, role='member'))
            u = User.query.get(uid)
            if u:
                added.append(u.name)
    if added:
        db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                               content=f'{session["user"]["name"]} 邀请了 {", ".join(added)} 加入群聊'))
        db.session.commit()
        socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
    return jsonify({'success': True, 'message': f'已邀请 {len(added)} 人'})

@app.route('/api/chat/rooms/<int:room_id>/members/<int:uid>', methods=['DELETE'])
@login_required
def api_remove_chat_member(room_id, uid):
    """移除成员 / 退出群聊"""
    user_id = session['user']['id']
    room = ChatRoom.query.get_or_404(room_id)
    if room.type == 'private':
        return jsonify({'success': False, 'message': '不能退出私聊'}), 400
    my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not my_member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    if uid == user_id:
        target_user = User.query.get(user_id)
        db.session.delete(my_member)
        db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                               content=f'{target_user.name} 退出了群聊'))
        db.session.commit()
        return jsonify({'success': True, 'message': '已退出群聊'})
    if my_member.role != 'admin':
        return jsonify({'success': False, 'message': '无权限'}), 403
    target_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
    if target_member:
        target_user = User.query.get(uid)
        db.session.delete(target_member)
        db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                               content=f'{target_user.name} 被移出了群聊'))
        db.session.commit()
        socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
    return jsonify({'success': True})

@app.route('/api/chat/rooms/<int:room_id>/read', methods=['POST'])
@login_required
def api_mark_chat_read(room_id):
    """标记已读"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if member:
        member.last_read_at = datetime.utcnow()
        db.session.commit()
        socketio.emit('read_update', {
            'room_id': room_id, 'user_id': user_id,
            'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S')
        }, room=f'room_{room_id}')
    return jsonify({'success': True})

@app.route('/api/chat/unread-total')
@login_required
def api_chat_unread_total():
    """获取所有聊天室未读总数"""
    user_id = session['user']['id']
    memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
    total = 0
    for m in memberships:
        if not m.muted:
            total += Message.query.filter(
                Message.room_id == m.room_id,
                Message.created_at > m.last_read_at,
                Message.sender_id != user_id
            ).count()
    return jsonify({'success': True, 'total': total})

@app.route('/api/chat/messages/<int:msg_id>/recall', methods=['POST'])
@login_required
def api_recall_message(msg_id):
    """撤回消息（2分钟内，仅发送者）"""
    user_id = session['user']['id']
    msg = Message.query.get_or_404(msg_id)
    if msg.sender_id != user_id:
        return jsonify({'success': False, 'message': '只能撤回自己的消息'}), 403
    if (datetime.utcnow() - msg.created_at).total_seconds() > 120:
        return jsonify({'success': False, 'message': '超过2分钟无法撤回'}), 400
    msg.recalled = True
    msg.content = ''
    msg.file_url = None
    msg.file_name = None
    db.session.commit()
    socketio.emit('message_recalled', {'message_id': msg.id, 'room_id': msg.room_id}, room=f'room_{msg.room_id}')
    return jsonify({'success': True})

# ========== SocketIO 事件处理 ==========

@socketio.on('connect')
def handle_connect():
    user = session.get('user')
    if not user:
        return False
    memberships = ChatRoomMember.query.filter_by(user_id=user['id']).all()
    for m in memberships:
        sio_join(f'room_{m.room_id}')

@socketio.on('send_message')
def handle_send_message(data):
    user = session.get('user')
    if not user:
        return
    user_id = user['id']
    room_id = data.get('room_id')
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return
    # 检查是否被禁言
    if member.muted:
        emit('error', {'message': '您已被禁言'})
        return
    mentions_raw = data.get('mentions', '')
    msg = Message(
        room_id=room_id, sender_id=user_id,
        type=data.get('type', 'text'), content=data.get('content', ''),
        file_url=data.get('file_url'), file_name=data.get('file_name'),
        reply_to_id=data.get('reply_to_id'),
        mentions=mentions_raw if mentions_raw else ''
    )
    db.session.add(msg)
    db.session.commit()
    sender = User.query.get(user_id)
    reply_to_data = None
    if msg.reply_to_id:
        reply_msg = Message.query.get(msg.reply_to_id)
        if reply_msg:
            rs = User.query.get(reply_msg.sender_id)
            reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知',
                             'content': reply_msg.content[:50] if reply_msg.content else '[文件]'}
    emit('new_message', {
        'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
        'sender_name': sender.name if sender else user['name'],
        'sender_avatar': (sender.avatar or '') if sender else '',
        'type': msg.type, 'content': msg.content,
        'file_url': msg.file_url, 'file_name': msg.file_name,
        'reply_to': reply_to_data,
        'mentions': mentions_raw,
        'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
    }, room=f'room_{room_id}')

@socketio.on('typing')
def handle_typing(data):
    user = session.get('user')
    if not user:
        return
    emit('user_typing', {
        'room_id': data.get('room_id'), 'user_id': user['id'], 'user_name': user['name']
    }, room=f'room_{data.get("room_id")}', include_self=False)

@socketio.on('mark_read')
def handle_mark_read(data):
    user = session.get('user')
    if not user:
        return
    room_id = data.get('room_id')
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user['id']).first()
    if member:
        member.last_read_at = datetime.utcnow()
        db.session.commit()
        emit('read_update', {
            'room_id': room_id, 'user_id': user['id'],
            'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S')
        }, room=f'room_{room_id}')

# ========== 群聊增强 API ==========

@app.route('/api/chat/rooms/<int:room_id>/announcement', methods=['GET'])
@login_required
def api_get_announcement(room_id):
    """获取群公告"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    room = ChatRoom.query.get_or_404(room_id)
    announcer = User.query.get(room.announcement_by) if room.announcement_by else None
    return jsonify({'success': True, 'announcement': room.announcement or '',
                    'by': announcer.name if announcer else '', 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M') if room.announcement_at else ''})

@app.route('/api/chat/rooms/<int:room_id>/announcement', methods=['POST'])
@login_required
def api_set_announcement(room_id):
    """设置群公告（仅管理员）"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member or member.role != 'admin':
        return jsonify({'success': False, 'message': '无权限'}), 403
    room = ChatRoom.query.get_or_404(room_id)
    data = request.get_json(force=True, silent=True)
    room.announcement = data.get('content', '').strip()
    room.announcement_by = user_id
    room.announcement_at = datetime.utcnow()
    db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                           content=f'{session["user"]["name"]} 更新了群公告'))
    db.session.commit()
    socketio.emit('announcement_updated', {
        'room_id': room_id, 'content': room.announcement,
        'by': session['user']['name'], 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M')
    }, room=f'room_{room_id}')
    return jsonify({'success': True})

@app.route('/api/chat/rooms/<int:room_id>/search')
@login_required
def api_chat_search(room_id):
    """搜索聊天记录"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    q = request.args.get('q', '').strip()
    if not q:
        return jsonify({'success': True, 'messages': []})
    msgs = Message.query.filter(
        Message.room_id == room_id, Message.recalled == False,
        Message.type.in_(['text', 'file']),
        Message.content.ilike(f'%{q}%')
    ).order_by(Message.created_at.desc()).limit(50).all()
    data = []
    for m in msgs:
        sender = User.query.get(m.sender_id)
        data.append({'id': m.id, 'sender_name': sender.name if sender else '未知',
                     'content': m.content, 'type': m.type,
                     'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')})
    return jsonify({'success': True, 'messages': data})

@app.route('/api/chat/rooms/<int:room_id>/files')
@login_required
def api_chat_files(room_id):
    """获取群文件列表"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    msgs = Message.query.filter(
        Message.room_id == room_id, Message.recalled == False,
        Message.type.in_(['file', 'image']), Message.file_url.isnot(None)
    ).order_by(Message.created_at.desc()).limit(100).all()
    data = []
    for m in msgs:
        sender = User.query.get(m.sender_id)
        data.append({'id': m.id, 'type': m.type, 'file_url': m.file_url,
                     'file_name': m.file_name or '未命名', 'sender_name': sender.name if sender else '未知',
                     'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')})
    return jsonify({'success': True, 'files': data})

@app.route('/api/chat/rooms/<int:room_id>/mute/<int:uid>', methods=['POST'])
@login_required
def api_mute_member(room_id, uid):
    """禁言/解禁成员（仅管理员）"""
    user_id = session['user']['id']
    my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not my_member or my_member.role != 'admin':
        return jsonify({'success': False, 'message': '无权限'}), 403
    target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
    if not target:
        return jsonify({'success': False, 'message': '成员不存在'}), 404
    if target.role == 'admin':
        return jsonify({'success': False, 'message': '不能禁言管理员'}), 400
    target.muted = not target.muted
    target_user = User.query.get(uid)
    action = '禁言' if target.muted else '解除禁言'
    db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                           content=f'{session["user"]["name"]} {action}了 {target_user.name}'))
    db.session.commit()
    socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
    return jsonify({'success': True, 'muted': target.muted})

@app.route('/api/chat/rooms/<int:room_id>/transfer/<int:uid>', methods=['POST'])
@login_required
def api_transfer_admin(room_id, uid):
    """转让群主"""
    user_id = session['user']['id']
    room = ChatRoom.query.get_or_404(room_id)
    if room.creator_id != user_id:
        return jsonify({'success': False, 'message': '只有群主可以转让'}), 403
    my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
    if not target:
        return jsonify({'success': False, 'message': '成员不存在'}), 404
    room.creator_id = uid
    target.role = 'admin'
    my_member.role = 'member'
    target_user = User.query.get(uid)
    db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                           content=f'{session["user"]["name"]} 将群主转让给了 {target_user.name}'))
    db.session.commit()
    socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
    return jsonify({'success': True})

@app.route('/api/chat/rooms/<int:room_id>/set-admin/<int:uid>', methods=['POST'])
@login_required
def api_set_admin(room_id, uid):
    """设置/取消管理员"""
    user_id = session['user']['id']
    room = ChatRoom.query.get_or_404(room_id)
    if room.creator_id != user_id:
        return jsonify({'success': False, 'message': '只有群主可以设置管理员'}), 403
    target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first()
    if not target:
        return jsonify({'success': False, 'message': '成员不存在'}), 404
    target.role = 'admin' if target.role == 'member' else 'member'
    target_user = User.query.get(uid)
    action = '设为管理员' if target.role == 'admin' else '取消管理员'
    db.session.add(Message(room_id=room_id, sender_id=user_id, type='system',
                           content=f'{session["user"]["name"]} 将 {target_user.name} {action}'))
    db.session.commit()
    socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
    return jsonify({'success': True, 'role': target.role})

@app.route('/api/chat/rooms/<int:room_id>/nickname', methods=['POST'])
@login_required
def api_set_nickname(room_id):
    """设置群内昵称"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403
    data = request.get_json(force=True, silent=True)
    member.nickname = data.get('nickname', '').strip()[:50]
    db.session.commit()
    return jsonify({'success': True})

@app.route('/api/chat/messages/<int:msg_id>/reactions', methods=['POST'])
@login_required
def api_toggle_reaction(msg_id):
    """添加/取消表情回应"""
    user_id = session['user']['id']
    data = request.get_json(force=True, silent=True)
    emoji = data.get('emoji', '')
    if not emoji:
        return jsonify({'success': False, 'message': '请选择表情'}), 400
    msg = Message.query.get_or_404(msg_id)
    member = ChatRoomMember.query.filter_by(room_id=msg.room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '无权限'}), 403
    existing = MessageReaction.query.filter_by(message_id=msg_id, user_id=user_id, emoji=emoji).first()
    if existing:
        db.session.delete(existing)
        action = 'removed'
    else:
        db.session.add(MessageReaction(message_id=msg_id, user_id=user_id, emoji=emoji))
        action = 'added'
    db.session.commit()
    # 获取该消息所有 reactions 汇总
    reactions = _get_reactions_summary(msg_id)
    socketio.emit('reaction_updated', {
        'message_id': msg_id, 'room_id': msg.room_id, 'reactions': reactions
    }, room=f'room_{msg.room_id}')
    return jsonify({'success': True, 'action': action, 'reactions': reactions})

def _get_reactions_summary(msg_id):
    """汇总消息的表情回应"""
    all_r = MessageReaction.query.filter_by(message_id=msg_id).all()
    summary = {}
    for r in all_r:
        if r.emoji not in summary:
            summary[r.emoji] = {'count': 0, 'users': []}
        summary[r.emoji]['count'] += 1
        u = User.query.get(r.user_id)
        if u:
            summary[r.emoji]['users'].append({'id': u.id, 'name': u.name})
    return summary

@app.route('/api/chat/rooms/<int:room_id>/voice', methods=['POST'])
@login_required
def api_upload_voice(room_id):
    """上传语音消息"""
    user_id = session['user']['id']
    member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first()
    if not member:
        return jsonify({'success': False, 'message': '无权限'}), 403
    if member.muted:
        return jsonify({'success': False, 'message': '您已被禁言'}), 403
    file = request.files.get('file')
    if not file:
        return jsonify({'success': False, 'message': '无文件'}), 400
    filename = secure_filename(f'voice_{user_id}_{int(time.time()*1000)}.webm')
    upload_dir = os.path.join('static', 'uploads', 'voice')
    os.makedirs(upload_dir, exist_ok=True)
    filepath = os.path.join(upload_dir, filename)
    file.save(filepath)
    url = '/' + filepath.replace('\\', '/')
    duration = request.form.get('duration', '0')
    msg = Message(room_id=room_id, sender_id=user_id, type='voice',
                  content=f'{duration}s', file_url=url)
    db.session.add(msg)
    db.session.commit()
    sender = User.query.get(user_id)
    socketio.emit('new_message', {
        'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
        'sender_name': sender.name if sender else '', 'sender_avatar': (sender.avatar or '') if sender else '',
        'type': 'voice', 'content': msg.content, 'file_url': url,
        'file_name': None, 'reply_to': None, 'mentions': '',
        'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
    }, room=f'room_{room_id}')
    return jsonify({'success': True})

# ========== 管理后台页面 ==========
@app.route('/admin')
def admin_dashboard():
    user = require_admin_or_teacher()
    return render_template('admin_dashboard.html')

@app.route('/admin/contests')
def admin_contests():
    user = require_admin_or_teacher()
    return render_template('admin_contests.html')

@app.route('/admin/exams')
def admin_exams():
    user = require_admin_or_teacher()
    return render_template('admin_exams.html')

@app.route('/admin/posts')
def admin_posts():
    user = require_admin_or_teacher()
    return render_template('admin_posts.html')

@app.route('/admin/users')
def admin_users():
    user = require_admin_or_teacher()
    return render_template('admin_users.html')

# ========== 管理后台 API ==========
@app.route('/api/admin/contests', methods=['GET', 'POST'])
def admin_contests_api():
    user = require_admin_or_teacher()
    if request.method == 'GET':
        contests = Contest.query.all()
        data = [{
            'id': c.id,
            'name': c.name,
            'organizer': c.organizer,
            'start_date': c.start_date,
            'description': c.description,
            'contact': c.contact if hasattr(c, 'contact') else '',
            'status': c.status
        } for c in contests]
        return jsonify({'success': True, 'contests': data})
    elif request.method == 'POST':
        data = request.get_json(force=True, silent=True)
        contest = Contest(
            name=data['name'],
            organizer=data['organizer'],
            start_date=data['start_date'],
            description=data['description'],
            contact=data.get('contact', ''),
            status=data['status']
        )
        db.session.add(contest)
        db.session.commit()
        return jsonify({'success': True})

@app.route('/api/admin/contests/<int:contest_id>', methods=['GET', 'PUT', 'DELETE'])
def admin_contest_detail(contest_id):
    user = require_admin_or_teacher()
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'})
    if request.method == 'GET':
        return jsonify({'success': True, 'contest': {
            'id': contest.id,
            'name': contest.name,
            'organizer': contest.organizer,
            'start_date': contest.start_date,
            'description': contest.description,
            'contact': contest.contact if hasattr(contest, 'contact') else '',
            'status': contest.status
        }})
    elif request.method == 'PUT':
        data = request.get_json(force=True, silent=True)
        contest.name = data.get('name', contest.name)
        contest.organizer = data.get('organizer', contest.organizer)
        contest.start_date = data.get('start_date', contest.start_date)
        contest.description = data.get('description', contest.description)
        contest.contact = data.get('contact', contest.contact if hasattr(contest, 'contact') else '')
        contest.status = data.get('status', contest.status)
        db.session.commit()
        return jsonify({'success': True})
    elif request.method == 'DELETE':
        db.session.delete(contest)
        db.session.commit()
        return jsonify({'success': True})

@app.route('/api/admin/contests/<int:contest_id>/abolish', methods=['POST'])
@admin_required
def admin_abolish_contest(contest_id):
    """管理员废止杯赛"""
    contest = Contest.query.get(contest_id)
    if not contest:
        return jsonify({'success': False, 'message': '杯赛不存在'}), 404
    if contest.status == 'abolished':
        return jsonify({'success': False, 'message': '杯赛已废止'}), 400
    contest.status = 'abolished'
    # 关闭该杯赛下所有考试
    for exam in contest.exams:
        exam.status = 'closed'
    # 通知杯赛负责人和老师
    for member in contest.members:
        add_notification(member.user_id, 'contest_result',
                         f'杯赛「{contest.name}」已被管理员废止。', from_user='系统')
    # 通知所有已报名用户
    registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all()
    for reg in registrations:
        add_notification(reg.user_id, 'contest_result',
                         f'您报名的杯赛「{contest.name}」已被废止。', from_user='系统')
    db.session.commit()
    return jsonify({'success': True, 'message': '杯赛已废止'})

@app.route('/api/admin/users', methods=['GET'])
def admin_users_api():
    user = require_admin_or_teacher()
    q = request.args.get('q', '')
    role = request.args.get('role', '')
    query = User.query
    if q:
        query = query.filter((User.name.contains(q)) | (User.email.contains(q)))
    if role:
        query = query.filter_by(role=role)
    users = query.all()
    data = [{
        'id': u.id,
        'name': u.name,
        'email': u.email,
        'role': u.role,
        'is_banned': u.is_banned,
        'created_at': u.created_at.strftime('%Y-%m-%d %H:%M')
    } for u in users]
    return jsonify({'success': True, 'users': data})

@app.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
def admin_delete_user(user_id):
    user = require_admin_or_teacher()
    if user_id == session.get('user', {}).get('id'):
        return jsonify({'success': False, 'message': '不能删除自己'})
    target = User.query.get(user_id)
    if not target:
        return jsonify({'success': False, 'message': '用户不存在'})
    if target.role == 'admin':
        return jsonify({'success': False, 'message': '不能删除管理员'})
    db.session.delete(target)
    db.session.commit()
    return jsonify({'success': True})

@app.route('/api/admin/posts', methods=['GET'])
def admin_posts_api():
    user = require_admin_or_teacher()
    q = request.args.get('q', '')
    tag = request.args.get('tag', '')
    query = Post.query
    if q:
        query = query.filter((Post.title.contains(q)) | (Post.content.contains(q)))
    if tag:
        query = query.filter_by(tag=tag)
    posts = query.all()
    data = [{
        'id': p.id,
        'title': p.title,
        'author': p.author.name if p.author else '未知用户',
        'tag': p.tag,
        'pinned': p.pinned,
        'created_at': p.created_at.strftime('%Y-%m-%d %H:%M')
    } for p in posts]
    return jsonify({'success': True, 'posts': data})

@app.route('/api/admin/posts/<int:post_id>', methods=['DELETE'])
def admin_delete_post(post_id):
    user = require_admin_or_teacher()
    post = Post.query.get(post_id)
    if post:
        db.session.delete(post)
        db.session.commit()
        return jsonify({'success': True})
    return jsonify({'success': False, 'message': '帖子不存在'})

@app.route('/api/admin/exams', methods=['GET'])
def admin_exams_api():
    user = require_admin_or_teacher()
    exams = Exam.query.all()
    data = [{
        'id': e.id,
        'title': e.title,
        'subject': e.subject,
        'creator_name': e.creator.name if e.creator else '',
        'status': e.status,
        'created_at': e.created_at.strftime('%Y-%m-%d %H:%M')
    } for e in exams]
    return jsonify({'success': True, 'exams': data})

@app.route('/api/admin/exams/<int:exam_id>/status', methods=['PUT'])
def admin_update_exam_status(exam_id):
    user = require_admin_or_teacher()
    exam = Exam.query.get(exam_id)
    if not exam:
        return jsonify({'success': False, 'message': '考试不存在'})
    data = request.get_json(force=True, silent=True)
    exam.status = data.get('status', exam.status)
    db.session.commit()
    return jsonify({'success': True})

@app.route('/api/admin/exams/<int:exam_id>', methods=['DELETE'])
def admin_delete_exam(exam_id):
    user = require_admin_or_teacher()
    exam = Exam.query.get(exam_id)
    if exam:
        db.session.delete(exam)
        db.session.commit()
        return jsonify({'success': True})
    return jsonify({'success': False, 'message': '考试不存在'})

@app.route('/api/admin/users/<int:user_id>/ban', methods=['PUT'])
@admin_required
def admin_ban_user(user_id):
    current = get_current_user()
    if user_id == current['id']:
        return jsonify({'success': False, 'message': '不能封禁自己'}), 400
    target = User.query.get(user_id)
    if not target:
        return jsonify({'success': False, 'message': '用户不存在'}), 404
    target.is_banned = not target.is_banned
    db.session.commit()
    return jsonify({'success': True, 'is_banned': target.is_banned})

@app.route('/api/admin/posts/<int:post_id>/pin', methods=['PUT'])
def admin_pin_post(post_id):
    user = require_admin_or_teacher()
    post = Post.query.get(post_id)
    if not post:
        return jsonify({'success': False, 'message': '帖子不存在'}), 404
    post.pinned = not post.pinned
    db.session.commit()
    return jsonify({'success': True, 'pinned': post.pinned})

@app.route('/api/admin/contest-applications', methods=['GET'])
@admin_required
def admin_contest_applications_api():
    apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all()
    data = [{
        'id': a.id,
        'user_name': a.user.name if a.user else '未知用户',
        'name': a.name,
        'organizer': a.organizer,
        'description': a.description,
        'contact': a.contact,
        'start_date': a.start_date or '',
        'end_date': a.end_date or '',
        'total_score': a.total_score or 150,
        'responsible_person': a.responsible_person or '',
        'responsible_phone': a.responsible_phone or '',
        'responsible_email': a.responsible_email or '',
        'organization': a.organization or '',
        'status': a.status,
        'applied_at': a.applied_at.strftime('%Y-%m-%d %H:%M') if a.applied_at else ''
    } for a in apps]
    return jsonify({'success': True, 'applications': data})

@app.route('/api/admin/stats', methods=['GET'])
def admin_stats():
    user = require_admin_or_teacher()
    stats = {
        'users': User.query.count(),
        'posts': Post.query.count(),
        'exams': Exam.query.count(),
        'contests': Contest.query.count(),
        'pending_teacher_apps': TeacherApplication.query.filter_by(status='pending').count(),
        'pending_contest_apps': ContestApplication.query.filter_by(status='pending').count()
    }
    return jsonify({'success': True, 'stats': stats})

@app.route('/api/admin/recent-activities', methods=['GET'])
def admin_recent_activities():
    user = require_admin_or_teacher()
    activities = []
    for s in Submission.query.order_by(Submission.submitted_at.desc()).limit(5).all():
        activities.append({
            'time': s.submitted_at.strftime('%Y-%m-%d %H:%M'),
            'user': s.user.name if s.user else '未知用户',
            'action': f'提交了试卷 ID {s.exam_id}'
        })
    for p in Post.query.order_by(Post.created_at.desc()).limit(5).all():
        activities.append({
            'time': p.created_at.strftime('%Y-%m-%d %H:%M'),
            'user': p.author.name if p.author else '未知用户',
            'action': f'发布了帖子《{p.title}》'
        })
    activities.sort(key=lambda x: x['time'], reverse=True)
    return jsonify({'success': True, 'activities': activities[:10]})

# ========== 初始化测试数据 ==========
with app.app_context():
    db.create_all()
    # 迁移：添加 avatar 列（如果不存在）
    try:
        from sqlalchemy import inspect as _inspect, text as _text
        insp = _inspect(db.engine)
        cols = [c['name'] for c in insp.get_columns('user')]
        if 'avatar' not in cols:
            with db.engine.connect() as conn:
                conn.execute(_text('ALTER TABLE user ADD COLUMN avatar VARCHAR(200) DEFAULT ""'))
                conn.commit()
    except Exception:
        pass
    # 迁移：添加 score_release_time 列（如果不存在）
    try:
        from sqlalchemy import inspect as _inspect2, text as _text2
        insp2 = _inspect2(db.engine)
        exam_cols = [c['name'] for c in insp2.get_columns('exam')]
        if 'score_release_time' not in exam_cols:
            with db.engine.connect() as conn:
                conn.execute(_text2('ALTER TABLE exam ADD COLUMN score_release_time DATETIME'))
                conn.commit()
    except Exception:
        pass
    # 迁移：添加 name_changed_at 列（如果不存在）
    try:
        from sqlalchemy import inspect as _inspect_nc, text as _text_nc
        insp_nc = _inspect_nc(db.engine)
        user_cols = [c['name'] for c in insp_nc.get_columns('user')]
        if 'name_changed_at' not in user_cols:
            with db.engine.connect() as conn:
                conn.execute(_text_nc('ALTER TABLE user ADD COLUMN name_changed_at DATETIME'))
                conn.commit()
    except Exception:
        pass
    # 迁移：为已有杯赛创建讨论群
    try:
        for contest in Contest.query.all():
            if not ChatRoom.query.filter_by(contest_id=contest.id).first():
                cr = ChatRoom(type='contest', name=contest.name + ' 讨论群',
                              creator_id=contest.created_by if isinstance(contest.created_by, int) else None,
                              contest_id=contest.id)
                db.session.add(cr)
                db.session.flush()
                if isinstance(contest.created_by, int):
                    db.session.add(ChatRoomMember(room_id=cr.id, user_id=contest.created_by, role='admin'))
        db.session.commit()
    except Exception:
        db.session.rollback()
    # 迁移：ContestApplication 添加 total_score, start_date, end_date
    try:
        from sqlalchemy import inspect as _inspect_ca, text as _text_ca
        insp_ca = _inspect_ca(db.engine)
        ca_cols = [c['name'] for c in insp_ca.get_columns('contest_application')]
        with db.engine.connect() as conn:
            if 'total_score' not in ca_cols:
                conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN total_score INTEGER DEFAULT 150'))
            if 'start_date' not in ca_cols:
                conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN start_date VARCHAR(20)'))
            if 'end_date' not in ca_cols:
                conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN end_date VARCHAR(20)'))
            conn.commit()
    except Exception:
        pass
    # 迁移：Contest 添加 total_score, visible
    try:
        from sqlalchemy import inspect as _inspect_ct, text as _text_ct
        insp_ct = _inspect_ct(db.engine)
        ct_cols = [c['name'] for c in insp_ct.get_columns('contest')]
        with db.engine.connect() as conn:
            if 'total_score' not in ct_cols:
                conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN total_score INTEGER DEFAULT 150'))
            if 'visible' not in ct_cols:
                conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN visible BOOLEAN DEFAULT 1'))
            conn.commit()
    except Exception:
        pass
    # 迁移：ContestApplication 添加报备字段
    try:
        from sqlalchemy import inspect as _inspect_ca2, text as _text_ca2
        insp_ca2 = _inspect_ca2(db.engine)
        ca2_cols = [c['name'] for c in insp_ca2.get_columns('contest_application')]
        with db.engine.connect() as conn:
            if 'responsible_person' not in ca2_cols:
                conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_person VARCHAR(80)'))
            if 'responsible_phone' not in ca2_cols:
                conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_phone VARCHAR(20)'))
            if 'responsible_email' not in ca2_cols:
                conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_email VARCHAR(120)'))
            if 'organization' not in ca2_cols:
                conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN organization VARCHAR(100)'))
            conn.commit()
    except Exception:
        pass
    # 迁移：Contest 添加报备字段
    try:
        from sqlalchemy import inspect as _inspect_ct2, text as _text_ct2
        insp_ct2 = _inspect_ct2(db.engine)
        ct2_cols = [c['name'] for c in insp_ct2.get_columns('contest')]
        with db.engine.connect() as conn:
            if 'responsible_person' not in ct2_cols:
                conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_person VARCHAR(80)'))
            if 'responsible_phone' not in ct2_cols:
                conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_phone VARCHAR(20)'))
            if 'responsible_email' not in ct2_cols:
                conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_email VARCHAR(120)'))
            if 'organization' not in ct2_cols:
                conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN organization VARCHAR(100)'))
            conn.commit()
    except Exception:
        pass
    # 迁移：Exam 添加加密字段
    try:
        from sqlalchemy import inspect as _inspect_ex, text as _text_ex
        insp_ex = _inspect_ex(db.engine)
        ex_cols = [c['name'] for c in insp_ex.get_columns('exam')]
        with db.engine.connect() as conn:
            if 'access_password' not in ex_cols:
                conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN access_password VARCHAR(128)'))
            if 'encrypted_questions' not in ex_cols:
                conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN encrypted_questions TEXT'))
            if 'is_encrypted' not in ex_cols:
                conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN is_encrypted BOOLEAN DEFAULT 0'))
            conn.commit()
    except Exception:
        pass
if __name__ == '__main__':
    socketio.run(app, debug=True,host='0.0.0.0', port=5080)