# """
# renderer.py
# Renderer pour générer le HTML des quiz
# """

# import json
# import base64
# import re
# import random
# from typing import Any, Dict, Optional

# from .database import QuizDatabase
# from .config import normalize_compare_answers


# def _coerce_float(v: Any) -> Optional[float]:
#     try:
#         if v is None:
#             return None
#         return float(v)
#     except Exception:
#         return None

# def _parse_bracket_list_key(key: str):
#     """Parse '[Paris, Londres]' → ['Paris', 'Londres'], ou None si pas bracket."""
#     s = str(key).strip()
#     if not (s.startswith('[') and s.endswith(']')):
#         return None
#     inner = s[1:-1].strip()
#     if not inner:
#         return []
#     return [x.strip() for x in inner.split(',') if x.strip()]


# def _flatten_score_block(cfg: dict) -> dict:
#     """
#     Aplatit le sous-dict 'score:' dans cfg si présent.
#     Gère les deux formes : avant et après normalisation i18n.
#     { 'score': { 'mode'/'scoring_mode': ..., 'min'/'min_score': ..., 'errors'/'errors_mode': {...} } }
#     → { 'scoring_mode': ..., 'min': ..., 'errors_mode': {...} }
#     Priorité : clés déjà présentes au niveau cfg > clés issues de score:
#     """
#     if not isinstance(cfg, dict):
#         return cfg
#     score_block = cfg.get('score')
#     if not isinstance(score_block, dict):
#         return cfg
#     out = dict(cfg)
#     del out['score']
#     # mode / scoring_mode → scoring_mode
#     if 'mode' in score_block and 'scoring_mode' not in out:
#         out['scoring_mode'] = score_block['mode']
#     if 'scoring_mode' in score_block and 'scoring_mode' not in out:
#         out['scoring_mode'] = score_block['scoring_mode']
#     # min / min_score → min
#     if 'min' in score_block and 'min' not in out:
#         out['min'] = score_block['min']
#     if 'min_score' in score_block and 'min' not in out:
#         out['min'] = score_block['min_score']
#     # errors / errors_mode → errors_mode
#     if 'errors' in score_block and 'errors_mode' not in out:
#         out['errors_mode'] = score_block['errors']
#     if 'errors_mode' in score_block and 'errors_mode' not in out:
#         out['errors_mode'] = score_block['errors_mode']
#     return out


# class QuizRenderer:
#     def __init__(self, config: dict, page_uuid: str, teacher_code_hash: str, i18n, language: str = 'en'):
#         self.config = config
#         self.page_uuid = page_uuid
#         self.teacher_code_hash = teacher_code_hash
#         self.i18n = i18n
#         self.language = language
#         self.db = None

#         try:
#             from pathlib import Path
#             db_path = Path.cwd() / 'docs' / 'quizzes.db'
#             if db_path.exists():
#                 self.db = QuizDatabase(str(db_path))
#         except Exception as e:
#             print(f"Warning: Could not connect to database: {e}")

#     # ─────────────────────────────────────────────────────────────────────────
#     # Main entry point
#     # ─────────────────────────────────────────────────────────────────────────

#     def render_quiz(self, quiz: dict) -> str:
#         quiz_id = f"mkq-{quiz['number']}"
#         quiz_type = quiz['type']
#         title = quiz['title']

#         questions_html = ''
#         for q in quiz.get('questions', []):
#             questions_html += self.render_question(quiz, q, quiz_id)

#         preamble_html = self._wrap_rich_content(quiz.get('preamble', ''))

#         correction_data = self._prepare_correction_data(quiz, quiz_id)
#         encrypted_corrections = self._encrypt_corrections(correction_data)

#         quiz_type_config = self.config.get(quiz_type, {})
#         _quiz_fm = quiz.get('config') or {}
#         _sc = _quiz_fm.get('show_correction')
#         show_correction = _sc if _sc is not None else quiz_type_config.get('show_correction', 'yes')
#         granularity = self.config.get('correction_granularity', 'exercise')

#         randomize_answers   = bool(quiz_type_config.get('randomize_answers', False))
#         randomize_questions = bool(quiz_type_config.get('randomize_questions', False))

#         icon_locked   = self.config.get('correction_locked_icon',   '❌')
#         icon_unlock   = self.config.get('correction_unlock_icon',   '🔐')
#         icon_unlocked = self.config.get('correction_unlocked_icon', '✅')

#         if show_correction == 'no':
#             unlock_html = f"""
# <span class="mkq-lock-indicator locked-permanent"
#       title="{self.i18n.t('ui.lock_tooltip_permanent', 'Corrections disabled')}"
#       data-lock-state="locked-permanent">
#     {icon_locked}
# </span>"""
#         elif show_correction == 'on_demand':
#             if granularity == 'page':
#                 if quiz['number'] == 1:
#                     unlock_html = f"""
# <button class="mkq-unlock-btn locked"
#         onclick="showUnlockModal('page')"
#         title="{self.i18n.t('ui.lock_tooltip_page', 'Click to unlock all quizzes on this page')}"
#         data-lock-state="locked">
#     {icon_unlock}
# </button>"""
#                 else:
#                     unlock_html = f"""
# <span class="mkq-lock-indicator locked-permanent"
#       title="{self.i18n.t('ui.lock_tooltip_page_locked', 'Unlock all quizzes using the button on the first quiz')}"
#       data-lock-state="locked-permanent">
#     {icon_locked}
# </span>"""
#             elif granularity == 'question':
#                 unlock_html = f"""
# <button class="mkq-unlock-btn locked"
#         onclick="showUnlockModal('exercise', '{quiz_id}')"
#         title="{self.i18n.t('ui.lock_tooltip_quiz_questions', 'Click to unlock all questions in this quiz')}"
#         data-lock-state="locked">
#     {icon_unlock}
# </button>"""
#             else:
#                 unlock_html = f"""
# <button class="mkq-unlock-btn locked"
#         onclick="showUnlockModal('exercise', '{quiz_id}')"
#         title="{self.i18n.t('ui.lock_tooltip_locked', 'Click to unlock this quiz')}"
#         data-lock-state="locked">
#     {icon_unlock}
# </button>"""
#         else:
#             unlock_html = f"""
# <span class="mkq-lock-indicator unlocked"
#       title="{self.i18n.t('ui.lock_tooltip_unlocked', 'Corrections always visible')}"
#       data-lock-state="unlocked">
#     {icon_unlocked}
# </span>"""

#         html = f"""
# <div class="mkdocs-superquiz admonition {quiz_type}"
#      data-quiz-id="{quiz_id}"
#      data-quiz-type="{quiz_type}"
#      data-page-uuid="{self.page_uuid}"
#      data-randomize-answers="{str(randomize_answers).lower()}"
#      data-randomize-questions="{str(randomize_questions).lower()}"
#      data-encrypted="{encrypted_corrections}">

#     <p class="admonition-title">
#         {title}
#         {unlock_html}
#     </p>

#     {preamble_html}

#     {questions_html}

#     <div class="quiz-actions">
#         <button class="mkq-validate-btn" onclick="validateQuiz('{quiz_id}')">
#             {self.i18n.t('ui.validate_button', 'Validate')}
#         </button>
#         <div class="mkq-score" id="score-{quiz_id}" style="display:none;"></div>
#     </div>
# </div>
# """
#         return html

#     # ─────────────────────────────────────────────────────────────────────────
#     # Render one question
#     # ─────────────────────────────────────────────────────────────────────────

#     def render_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
#         quiz_type = quiz.get('type')
#         qid = question.get('number', 0)
#         is_meta = bool(question.get('is_meta')) or (qid == 0)
#         if is_meta:
#             return self._render_meta_preamble(question)
#         if quiz_type in ('mcquiz', 'scquiz'):
#             return self._render_choice_question(quiz, question, quiz_id)
#         if quiz_type == 'fillblanks':
#             return self._render_fillblanks_question(quiz, question, quiz_id)
#         if quiz_type == 'scdropdown':
#             return self._render_scdropdown_question(quiz, question, quiz_id)
#         return ''

#     # ─────────────────────────────────────────────────────────────────────────
#     # Helpers
#     # ─────────────────────────────────────────────────────────────────────────

#     def _wrap_rich_content(self, rich_content: str) -> str:
#         if not rich_content or not rich_content.strip():
#             return ''
#         return f'<div class="mkq-rich-content">{rich_content}</div>'

#     def _render_meta_preamble(self, question: dict) -> str:
#         rich_html = self._wrap_rich_content(question.get('rich_content', ''))
#         if not rich_html:
#             return ''
#         return f'<div class="mkq-preamble mkq-meta">{rich_html}</div>'

#     # ─────────────────────────────────────────────────────────────────────────
#     # Choice question (mcquiz / scquiz)
#     # ─────────────────────────────────────────────────────────────────────────


#     def _render_choice_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
#         question_id = question.get('number', 0)
#         input_type = 'checkbox' if quiz['type'] == 'mcquiz' else 'radio'
#         raw_or_html = question.get('text_html') or question.get('text', '')
#         question_text = self._render_math(raw_or_html)
#         rich_html = self._wrap_rich_content(question.get('rich_content', ''))

#         granularity = self.config.get('correction_granularity', 'all')
#         question_unlock_html = ''
#         if granularity == 'question':
#             page = self.db.get_page_by_uuid(self.page_uuid) if self.db else None
#             corrections_locked = page.get('corrections_locked', False) if page else False
#             if corrections_locked:
#                 question_unlock_html = f"""
# <button class="mkq-unlock-btn locked"
#         onclick="showUnlockModal('question', '{quiz_id}', '{question_id}')"
#         title="{self.i18n.t('ui.lock_tooltip_locked', 'Click to unlock corrections')}"
#         data-lock-state="locked">
#     🔒
# </button>"""

#         qcfg_for_rand = question.get('config') or {}
#         q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)

#         # ✅ Normaliser : 'yes'/True → True, 'no'/False → False, None → absent
#         q_rand_attr = ''
#         if q_randomize_answers in (True, 'yes', 'true', 1):
#             q_rand_attr = ' data-randomize-answers="true"'
#         elif q_randomize_answers in (False, 'no', 'false', 0):
#             q_rand_attr = ' data-randomize-answers="false"'

#         answers_html = ''
#         for idx, answer in enumerate(question.get('answers', [])):
#             answer_id = f"{quiz_id}-q{question_id}-a{idx}"
#             answer_text = self._render_math(answer['text'])
#             answers_html += f"""
# <label class="mkq-answer" data-answer-text="{answer['text'].replace('"', '&quot;')}">
#     <input type="{input_type}" id="{answer_id}" name="{quiz_id}-q{question_id}" value="{idx}" data-answer-index="{idx}">
#     <span class="mkq-answer-text">{answer_text}</span>
#     <span class="mkq-answer-feedback" style="display:none;"></span>
# </label>"""

#         if not rich_html or not rich_html.strip():
#             return f"""
# <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
#     <div class="mkq-question-text">
#         <span class="mkq-number">{question_id}.</span> {question_text} {question_unlock_html}
#     </div>
#     <div class="mkq-answers">{answers_html}</div>
# </div>"""

#         return f"""
# <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
#     <div class="mkq-question-text mkq-question-number-only">
#         <span class="mkq-number">{question_id}.</span> {question_unlock_html}
#     </div>
#     {rich_html}
#     <div class="mkq-question-text mkq-question-body">{question_text}</div>
#     <div class="mkq-answers">{answers_html}</div>
# </div>"""



#     # ─────────────────────────────────────────────────────────────────────────
#     # Fillblanks question
#     # ─────────────────────────────────────────────────────────────────────────

#     def _render_fillblanks_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
#         question_id = question.get('number', 0)
#         question_text = question.get('text', '')
#         rich_html = self._wrap_rich_content(question.get('rich_content', ''))

#         blank_pattern = r'\[([^\]]+)\](\{[^}]*\})?'

#         def make_replacer(qid, qnum):
#             counters = [0]
#             def replacer(match):
#                 input_id = f"{qid}-q{qnum}-blank{counters[0]}"
#                 h = f'<input type="text" class="mkq-blank-input" id="{input_id}" data-blank-index="{counters[0]}">'
#                 counters[0] += 1
#                 return h
#             return replacer

#         blank_html = re.sub(blank_pattern, make_replacer(quiz_id, question_id), question_text)
#         blank_html = self._render_math(blank_html)

#         if not rich_html or not rich_html.strip():
#             return f"""
# <div class="mkq-question" data-question-id="{question_id}">
#     <div class="mkq-question-text">
#         <span class="mkq-number">{question_id}.</span> {blank_html}
#     </div>
# </div>"""

#         return f"""
# <div class="mkq-question" data-question-id="{question_id}">
#     <div class="mkq-question-text mkq-question-number-only">
#         <span class="mkq-number">{question_id}.</span>
#     </div>
#     {rich_html}
#     <div class="mkq-question-text mkq-question-body">{blank_html}</div>
# </div>"""

#     # ─────────────────────────────────────────────────────────────────────────
#     # Scdropdown question
#     # ─────────────────────────────────────────────────────────────────────────

# #     def _render_scdropdown_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
# #         question_id = question.get('number', 0)
# #         question_text = question.get('text', '')
# #         rich_html = self._wrap_rich_content(question.get('rich_content', ''))

# #         scdropdowns = question.get('scdropdowns')
# #         if not scdropdowns:
# #             dd = question.get('scdropdown')
# #             scdropdowns = [dd] if dd else []

# #         rendered_text = question_text
# #         for dd_index, scdropdown_data in enumerate(scdropdowns):
# #             if not scdropdown_data:
# #                 continue
# #             scdropdown_id = f"{quiz_id}-q{question_id}-scdropdown-{dd_index}"
# #             scdropdown_html = (
# #                 f'<div class="mkq-custom-scdropdown" id="{scdropdown_id}"'
# #                 f' data-quiz-id="{quiz_id}" data-question-id="{question_id}">'
# #                 '<button type="button" class="mkq-custom-scdropdown-btn"'
# #                 ' onclick="window.toggleScdropdownQuiz(this); return false;">'
# #                 '<span class="mkq-custom-scdropdown-value">--</span>'
# #                 '<span class="mkq-custom-scdropdown-arrow">▾</span>'
# #                 '</button>'
# #                 '<div class="mkq-custom-scdropdown-options" style="display: none;">'
# #             )
# #             for idx, option in enumerate(scdropdown_data.get('options', [])):
# #                 selected = 'selected' if idx == 0 else ''
# #                 escaped_option = option.replace('"', '&quot;')
# #                 scdropdown_html += (
# #                     f'<div class="mkq-custom-scdropdown-option {selected}"'
# #                     f' data-value="{idx}"'
# #                     f' data-option-text="{escaped_option}"'
# #                     f' onclick="window.selectScdropdownQuizOption(this); return false;">'
# #                     f'{option}</div>'
# #                 )
# #             scdropdown_html += '</div></div>'
# #             placeholder = f'___DROPDOWN_{dd_index}___'
# #             if placeholder in rendered_text:
# #                 rendered_text = rendered_text.replace(placeholder, scdropdown_html, 1)
# #             else:
# #                 rendered_text = rendered_text.replace('___DROPDOWN___', scdropdown_html, 1)

# #         rendered_text = self._render_math(rendered_text)

# #         qcfg_for_rand = question.get('config') or {}
# #         q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)
# #         q_rand_attr = ''
# #         if q_randomize_answers is True:
# #             q_rand_attr = ' data-randomize-answers="true"'
# #         elif q_randomize_answers is False:
# #             q_rand_attr = ' data-randomize-answers="false"'

# #         if not rich_html or not rich_html.strip():
# #             return f"""
# # <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
# #     <div class="mkq-question-text">
# #         <span class="mkq-number">{question_id}.</span> {rendered_text}
# #     </div>
# # </div>"""

# #         return f"""
# # <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
# #     <div class="mkq-question-text mkq-question-number-only">
# #         <span class="mkq-number">{question_id}.</span>
# #     </div>
# #     {rich_html}
# #     <div class="mkq-question-text mkq-question-body">{rendered_text}</div>
# # </div>"""

#     def _render_scdropdown_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
#         question_id = question.get('number', 0)
#         question_text = question.get('text', '')
#         rich_html = self._wrap_rich_content(question.get('rich_content', ''))

#         scdropdowns = question.get('scdropdowns')
#         if not scdropdowns:
#             dd = question.get('scdropdown')
#             scdropdowns = [dd] if dd else []

#         rendered_text = question_text
#         for dd_index, scdropdown_data in enumerate(scdropdowns):
#             if not scdropdown_data:
#                 continue
#             scdropdown_id = f"{quiz_id}-q{question_id}-scdropdown-{dd_index}"
#             scdropdown_html = (
#                 f'<div class="mkq-custom-scdropdown" id="{scdropdown_id}"'
#                 f' data-quiz-id="{quiz_id}" data-question-id="{question_id}">'
#                 '<button type="button" class="mkq-custom-scdropdown-btn"'
#                 ' onclick="window.toggleScdropdownQuiz(this); return false;">'
#                 '<span class="mkq-custom-scdropdown-value">--</span>'
#                 '<span class="mkq-custom-scdropdown-arrow">▾</span>'
#                 '</button>'
#                 '<div class="mkq-custom-scdropdown-options" style="display: none;">'
#             )
#             for idx, option in enumerate(scdropdown_data.get('options', [])):
#                 selected = 'selected' if idx == 0 else ''
#                 escaped_option = option.replace('"', '&quot;')
#                 scdropdown_html += (
#                     f'<div class="mkq-custom-scdropdown-option {selected}"'
#                     f' data-value="{idx}"'
#                     f' data-option-text="{escaped_option}"'
#                     f' onclick="window.selectScdropdownQuizOption(this); return false;">'
#                     f'{option}</div>'
#                 )
#             scdropdown_html += '</div></div>'
#             placeholder = f'___DROPDOWN_{dd_index}___'
#             if placeholder in rendered_text:
#                 rendered_text = rendered_text.replace(placeholder, scdropdown_html, 1)
#             else:
#                 rendered_text = rendered_text.replace('___DROPDOWN___', scdropdown_html, 1)

#         rendered_text = self._render_math(rendered_text)

#         qcfg_for_rand = question.get('config') or {}
#         q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)
#         q_rand_attr = ''
#         if q_randomize_answers in (True, 'yes', 'true', 1):
#             q_rand_attr = ' data-randomize-answers="true"'
#         elif q_randomize_answers in (False, 'no', 'false', 0):
#             q_rand_attr = ' data-randomize-answers="false"'

#         if not rich_html or not rich_html.strip():
#             return f"""
# <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
#     <div class="mkq-question-text">
#         <span class="mkq-number">{question_id}.</span> {rendered_text}
#     </div>
# </div>"""

#         return f"""
# <div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
#     <div class="mkq-question-text mkq-question-number-only">
#         <span class="mkq-number">{question_id}.</span>
#     </div>
#     {rich_html}
#     <div class="mkq-question-text mkq-question-body">{rendered_text}</div>
# </div>"""


#     # ─────────────────────────────────────────────────────────────────────────
#     # Math rendering
#     # ─────────────────────────────────────────────────────────────────────────

#     def _render_math(self, text: str) -> str:
#         def protect_html(match):
#             return match.group(0).replace('$', '&#36;')
#         text = re.sub(r'<[^>]+>', protect_html, text)
#         text = re.sub(r'\$\$(.*?)\$\$', lambda m: f'\\[{m.group(1)}\\]', text, flags=re.DOTALL)
#         text = re.sub(r'(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)', lambda m: f'\\({m.group(1)}\\)', text)
#         text = text.replace('&#36;', '$')
#         return text

#     # ─────────────────────────────────────────────────────────────────────────
#     # Correction data helpers
#     # ─────────────────────────────────────────────────────────────────────────

#     def _resolve_question_points(self, quiz_type: str, question: dict) -> float:
#         qcfg = question.get('config') or {}
#         if isinstance(qcfg, dict) and 'points' in qcfg and qcfg['points'] is not None:
#             try:
#                 return float(qcfg['points'])
#             except Exception:
#                 pass
#         if question.get('points') is not None:
#             try:
#                 return float(question['points'])
#             except Exception:
#                 pass
#         qt_cfg = self.config.get(quiz_type, {}) if isinstance(self.config, dict) else {}
#         try:
#             return float(qt_cfg.get('default_points', 1))
#         except Exception:
#             return 1.0


#     def _get_compare_answers_for_quiz(self, quiz: dict) -> dict:
#         import copy
#         gcfg = self.config.get('fillblanks', {}) if isinstance(self.config, dict) else {}
#         global_raw = gcfg.get('compare_answers')
#         base = normalize_compare_answers(global_raw if isinstance(global_raw, dict) else {})
#         quiz_cfg = quiz.get('config') or {}
#         # ✅ Lire 'compare_answers' OU 'match' (alias front-matter)
#         quiz_raw = quiz_cfg.get('compare_answers') or quiz_cfg.get('match') \
#             if isinstance(quiz_cfg, dict) else None
#         if not quiz_raw or not isinstance(quiz_raw, dict):
#             return base
#         # ✅ Passer par normalize_compare_answers pour normaliser l'équivalence
#         quiz_norm = normalize_compare_answers(quiz_raw)
#         merged = copy.deepcopy(base)
#         merged['ignore'].update(quiz_norm['ignore'])
#         merged['equivalence'].update(quiz_norm['equivalence'])
#         return merged


#     def _get_compare_answers_for_question(self, question: dict, quiz_compare: dict) -> Optional[dict]:
#         import copy
#         qcfg = question.get('config') or {}
#         if not isinstance(qcfg, dict):
#             return None
#         # raw = qcfg.get('compare_answers')
#         raw = qcfg.get('compare_answers') or qcfg.get('match')
        
#         if not raw or not isinstance(raw, dict):
#             return None
#         merged = copy.deepcopy(quiz_compare)
#         raw_norm = normalize_compare_answers(raw)
#         merged['ignore'].update(raw_norm['ignore'])
#         merged['equivalence'].update(raw_norm['equivalence'])
#         return merged

#     # def _normalize_errors_mode_choice(self, question: dict, quiz_cfg: dict, qcfg: dict = None) -> dict:
#     #     RESERVED_KEYS = ('default_false', 'default', 'min', 'max')

#     #     if qcfg is None:
#     #         raw_qcfg = question.get('config') or {}
#     #         qcfg = _flatten_score_block(raw_qcfg) if isinstance(raw_qcfg, dict) else {}

#     #     def _extract_rules(em_dict: dict) -> list:
#     #         rules = []
#     #         if not isinstance(em_dict, dict):
#     #             return rules
#     #         for k, v in em_dict.items():
#     #             if k in RESERVED_KEYS:
#     #                 continue
#     #             score_val = _coerce_float(v)
#     #             if score_val is not None:
#     #                 rules.append({'key': str(k), 'score': score_val})
#     #         return rules

#     #     result = {'rules': [], 'default_false': None}

#     #     # Priorité 3 : quiz_cfg.errors_mode
#     #     quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
#     #     if isinstance(quiz_em, dict):
#     #         result['rules'] = _extract_rules(quiz_em)
#     #         df = quiz_em.get('default_false') if 'default_false' in quiz_em else quiz_em.get('default')
#     #         result['default_false'] = _coerce_float(df)

#     #     # Priorité 2 : qcfg.errors_mode (answers: --- block, aplati)
#     #     qcfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
#     #     if isinstance(qcfg_em, dict):
#     #         result['rules'] = _extract_rules(qcfg_em)
#     #         df = qcfg_em.get('default_false') if 'default_false' in qcfg_em else qcfg_em.get('default')
#     #         result['default_false'] = _coerce_float(df)

#     #     # Priorité 1 : question.errors_mode (inline {})
#     #     q_em = question.get('errors_mode')
#     #     if isinstance(q_em, dict):
#     #         result['rules'] = _extract_rules(q_em)
#     #         df = q_em.get('default_false') if 'default_false' in q_em else q_em.get('default')
#     #         result['default_false'] = _coerce_float(df)

#     #     return result

#     def _normalize_errors_mode_choice(self, question: dict, quiz_cfg: dict, qcfg: dict = None) -> dict:
#         RESERVED_KEYS = ('default_false', 'default', 'min', 'max')

#         if qcfg is None:
#             raw_qcfg = question.get('config') or {}
#             qcfg = _flatten_score_block_static(raw_qcfg) if isinstance(raw_qcfg, dict) else {}

#         def _extract_rules(em_dict: dict) -> list:
#             rules = []
#             if not isinstance(em_dict, dict):
#                 return rules
#             for k, v in em_dict.items():
#                 if k in RESERVED_KEYS:
#                     continue
#                 score_val = _coerce_float(v)
#                 if score_val is not None:
#                     rules.append({'key': str(k), 'score': score_val})
#             return rules

#         result = {'rules': [], 'default_false': None}

#         # ✅ Syntaxe 2 uniquement : errors_mode vient du bloc par-question (qcfg)
#         # quiz_cfg.errors_mode n'est plus supporté pour mcquiz/scquiz
#         qcfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None

#         # Fallback : inline errors_mode sur la question (syntaxe {} inline)
#         q_em = question.get('errors_mode')

#         em = qcfg_em if isinstance(qcfg_em, dict) else (q_em if isinstance(q_em, dict) else None)

#         if isinstance(em, dict):
#             result['rules'] = _extract_rules(em)
#             df = em.get('default_false') if 'default_false' in em else em.get('default')
#             result['default_false'] = _coerce_float(df)

#         return result


#     def _normalize_errors_mode_fillblanks(self, question: dict) -> dict:
#         out = {'blanks': []}
#         qcfg = question.get('config') or {}
#         cfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
#         cfg_em = cfg_em if isinstance(cfg_em, dict) else {}

#         for i, blank in enumerate(question.get('blanks', [])):
#             em = blank.get('errors_mode')
#             if not isinstance(em, dict):
#                 # ✅ Chercher blank0/blank1 OU 0/1 (clés numériques)
#                 em = cfg_em.get(f'blank{i}')
#             if not isinstance(em, dict):
#                 em = cfg_em.get(i)          # clé int (parsée comme int par _coerce_scalar)
#             if not isinstance(em, dict):
#                 em = cfg_em.get(str(i))     # clé string '0', '1'

#             obj = {'default_false': None, 'rules': []}
#             if isinstance(em, dict):
#                 if 'default_false' in em:
#                     obj['default_false'] = _coerce_float(em.get('default_false'))
#                 elif 'default' in em:
#                     obj['default_false'] = _coerce_float(em.get('default'))
#                 for k, v in em.items():
#                     if k in ('default_false', 'default'):
#                         continue
#                     sc = _coerce_float(v)
#                     if sc is None:
#                         continue
#                     obj['rules'].append({'answer': str(k), 'score': sc})
#             out['blanks'].append(obj)
#         return out


#     def _normalize_errors_mode_scdropdown(self, question: dict) -> dict:
#         out = {'scdropdowns': []}
#         scdropdowns = question.get('scdropdowns') or []
#         if not scdropdowns:
#             dd = question.get('scdropdown')
#             scdropdowns = [dd] if dd else []

#         qcfg = question.get('config') or {}
#         cfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
#         cfg_em = cfg_em if isinstance(cfg_em, dict) else {}

#         for i, dd in enumerate(scdropdowns):
#             em = dd.get('errors_mode') if isinstance(dd, dict) else None
#             if not isinstance(em, dict):
#                 em = cfg_em.get(f'scdropdown{i}')
#             if not isinstance(em, dict):
#                 em = cfg_em.get(i)        # ✅ clé int
#             if not isinstance(em, dict):
#                 em = cfg_em.get(str(i))   # ✅ clé string '0', '1'

#             obj = {'default_false': None, 'rules': []}
#             if isinstance(em, dict):
#                 if 'default_false' in em:
#                     obj['default_false'] = _coerce_float(em.get('default_false'))
#                 elif 'default' in em:
#                     obj['default_false'] = _coerce_float(em.get('default'))
#                 for k, v in em.items():
#                     if k in ('default_false', 'default'):
#                         continue
#                     sc = _coerce_float(v)
#                     if sc is None:
#                         continue
#                     obj['rules'].append({'option': str(k), 'score': sc})
#             out['scdropdowns'].append(obj)
#         return out


#     def _prepare_correction_data(self, quiz: dict, quiz_id: str) -> dict:
#         quiz_type = quiz.get('type')
#         qt_cfg = self.config.get(quiz_type, {}) if isinstance(self.config, dict) else {}

#         quiz_cfg = quiz.get('config') or {}
#         if not isinstance(quiz_cfg, dict):
#             quiz_cfg = {}

#         # ✅ Aplatir le bloc score: du quiz
#         quiz_cfg = _flatten_score_block(quiz_cfg)

#         scoring_mode = quiz_cfg.get('scoring_mode') or qt_cfg.get('scoring_mode') or 'all_or_nothing'
#         default_false = qt_cfg.get('default_false', qt_cfg.get('default', 0))
#         try:
#             default_false = float(default_false)
#         except Exception:
#             default_false = 0.0

#         _SENTINEL = object()

#         # ✅ Priorité 1 : errors_mode.min au niveau quiz_cfg (score: errors: min: none)
#         def _resolve_min(raw):
#             if raw is None:
#                 return None
#             if isinstance(raw, str) and raw.strip().lower() == 'none':
#                 return None
#             return _coerce_float(raw)

#         quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
#         if isinstance(quiz_em, dict) and 'min' in quiz_em:
#             min_score = _resolve_min(quiz_em['min'])
#         else:
#             # Priorité 2 : qt_cfg.min_score ou config globale
#             _ms = qt_cfg.get('min_score', _SENTINEL)
#             if _ms is _SENTINEL:
#                 _ms = self.config.get('min_score', _SENTINEL)
#             min_score = None if _ms is _SENTINEL else _ms

#         clamp_max = bool(self.config.get('clamp_max_score', True))

#         # ✅ save_answers : lire depuis quiz_cfg, défaut True
#         _save = quiz_cfg.get('save_answers', True)
#         if isinstance(_save, str):
#             _save = _save.lower() not in ('no', 'false', '0')
#         save_answers = bool(_save)

#         quiz_compare = self._get_compare_answers_for_quiz(quiz) if quiz_type == 'fillblanks' else None

#         correction_data: Dict[str, Any] = {
#             'quiz_id':        quiz['number'],
#             'quiz_dom_id':    quiz_id,
#             'quiz_type':      quiz_type,
#             'scoring_mode':   scoring_mode,
#             'default_false':  default_false,
#             'min_score':      min_score,
#             'clamp_max_score': clamp_max,
#             'save_answers':   save_answers,
#             'compare_answers': quiz_compare,
#             'questions':      []
#         }

#         def _resolve_min(raw):
#             if raw is None:
#                 return None
#             if isinstance(raw, str) and raw.strip().lower() == 'none':
#                 return None
#             return _coerce_float(raw)

#         for question in quiz.get('questions', []):
#             if question.get('is_meta') or question.get('number') == 0:
#                 continue

#             points = self._resolve_question_points(quiz_type, question)

#             raw_qcfg = question.get('config') or {}
#             if not isinstance(raw_qcfg, dict):
#                 raw_qcfg = {}
#             qcfg = _flatten_score_block(raw_qcfg)

#             # DEBUG — à supprimer après validation
#             import pprint
#             print(f"DEBUG raw_qcfg q {question['number']} = {pprint.pformat(qcfg)}")

#             q_default_false = None
#             if 'default_false' in qcfg:
#                 q_default_false = _coerce_float(qcfg.get('default_false'))
#             elif 'default' in qcfg:
#                 q_default_false = _coerce_float(qcfg.get('default'))

#             q_min_score = min_score

#             quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
#             if isinstance(quiz_em, dict) and 'min' in quiz_em:
#                 q_min_score = _resolve_min(quiz_em['min'])

#             qcfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
#             if isinstance(qcfg_em, dict) and 'min' in qcfg_em:
#                 q_min_score = _resolve_min(qcfg_em['min'])

#             if 'min' in qcfg:
#                 q_min_score = _resolve_min(qcfg.get('min'))
#             elif 'min_score' in qcfg:
#                 q_min_score = _resolve_min(qcfg.get('min_score'))

#             question_data: Dict[str, Any] = {
#                 'number':        question['number'],
#                 'text':          question.get('text', ''),
#                 'points':        points,
#                 'scoring_mode':  qcfg.get('scoring_mode') or scoring_mode,
#                 'default_false': default_false,
#                 'question_default_false': q_default_false,
#                 'min_score':     q_min_score,
#                 'clamp_max_score': clamp_max,
#             }

#             if quiz_type in ['mcquiz', 'scquiz']:
#                 question_data['answers_text'] = [a.get('text', '') for a in question.get('answers', [])]
#                 question_data['correct_answers'] = [
#                     idx for idx, answer in enumerate(question.get('answers', []))
#                     if answer.get('is_correct')
#                 ]
#                 question_data['correct_answers_text'] = [
#                     answer.get('text', '') for answer in question.get('answers', [])
#                     if answer.get('is_correct')
#                 ]
#                 question_data['errors_mode'] = self._normalize_errors_mode_choice(question, quiz_cfg, qcfg)

#             elif quiz_type == 'fillblanks':
#                 question_data['blanks'] = question.get('blanks', [])
#                 question_data['errors_mode'] = self._normalize_errors_mode_fillblanks(question)
#                 q_compare = self._get_compare_answers_for_question(question, quiz_compare)
#                 if q_compare is not None:
#                     question_data['compare_answers'] = q_compare

#             elif quiz_type == 'scdropdown':
#                 scdropdowns = question.get('scdropdowns')
#                 question_data['scdropdowns'] = scdropdowns if scdropdowns else [question.get('scdropdown', {})]
#                 question_data['errors_mode'] = self._normalize_errors_mode_scdropdown(question)

#             correction_data['questions'].append(question_data)

#         return correction_data


#     def _encrypt_corrections(self, correction_data: dict) -> str:
#         key = self.config.get('encryption_key', 'default-key-change-me')
#         json_str = json.dumps(correction_data)
#         key_bytes = key.encode('utf-8')
#         data_bytes = json_str.encode('utf-8')
#         encrypted = bytearray(byte ^ key_bytes[i % len(key_bytes)] for i, byte in enumerate(data_bytes))
#         return base64.b64encode(bytes(encrypted)).decode('utf-8')

# ================================================================================================================
# ================================================================================================================
# ================================================================================================================

"""
renderer.py
Renderer pour générer le HTML des quiz
"""

import json
import base64
import re
import random
from typing import Any, Dict, Optional

from .database import QuizDatabase
from .config import normalize_compare_answers


def _coerce_float(v: Any) -> Optional[float]:
    try:
        if v is None:
            return None
        return float(v)
    except Exception:
        return None

def _parse_bracket_list_key(key: str):
    s = str(key).strip()
    if not (s.startswith('[') and s.endswith(']')):
        return None
    inner = s[1:-1].strip()
    if not inner:
        return []
    return [x.strip() for x in inner.split(',') if x.strip()]


def _flatten_score_block(cfg: dict) -> dict:
    if not isinstance(cfg, dict):
        return cfg
    score_block = cfg.get('score')
    if not isinstance(score_block, dict):
        return cfg
    out = dict(cfg)
    del out['score']
    if 'mode' in score_block and 'scoring_mode' not in out:
        out['scoring_mode'] = score_block['mode']
    if 'scoring_mode' in score_block and 'scoring_mode' not in out:
        out['scoring_mode'] = score_block['scoring_mode']
    if 'min' in score_block and 'min' not in out:
        out['min'] = score_block['min']
    if 'min_score' in score_block and 'min' not in out:
        out['min'] = score_block['min_score']
    if 'errors' in score_block and 'errors_mode' not in out:
        out['errors_mode'] = score_block['errors']
    if 'errors_mode' in score_block and 'errors_mode' not in out:
        out['errors_mode'] = score_block['errors_mode']
    return out


def _flatten_score_block_static(cfg: dict) -> dict:
    return _flatten_score_block(cfg)


class QuizRenderer:
    def __init__(self, config: dict, page_uuid: str, teacher_code_hash: str, i18n, language: str = 'en'):
        self.config = config
        self.page_uuid = page_uuid
        self.teacher_code_hash = teacher_code_hash
        self.i18n = i18n
        self.language = language
        self.db = None

        try:
            from pathlib import Path
            db_path = Path.cwd() / 'docs' / 'quizzes.db'
            if db_path.exists():
                self.db = QuizDatabase(str(db_path))
        except Exception as e:
            print(f"Warning: Could not connect to database: {e}")

    # ─────────────────────────────────────────────────────────────────────────
    # Main entry point
    # ─────────────────────────────────────────────────────────────────────────

    def render_quiz(self, quiz: dict) -> str:
        quiz_id = f"mkq-{quiz['number']}"
        quiz_type = quiz['type']
        title = quiz['title']

        questions_html = ''
        for q in quiz.get('questions', []):
            questions_html += self.render_question(quiz, q, quiz_id)

        preamble_html = self._wrap_rich_content(quiz.get('preamble', ''))

        correction_data = self._prepare_correction_data(quiz, quiz_id)
        encrypted_corrections = self._encrypt_corrections(correction_data)

        quiz_type_config = self.config.get(quiz_type, {})
        _quiz_fm = quiz.get('config') or {}
        _sc = _quiz_fm.get('show_correction')
        show_correction = _sc if _sc is not None else quiz_type_config.get('show_correction', 'yes')
        granularity = self.config.get('correction_granularity', 'exercise')

        randomize_answers   = bool(quiz_type_config.get('randomize_answers', False))
        randomize_questions = bool(quiz_type_config.get('randomize_questions', False))

        icon_locked   = self.config.get('correction_locked_icon',   '❌')
        icon_unlock   = self.config.get('correction_unlock_icon',   '🔐')
        icon_unlocked = self.config.get('correction_unlocked_icon', '✅')

        if show_correction == 'no':
            unlock_html = f"""
<span class="mkq-lock-indicator locked-permanent"
      title="{self.i18n.t('ui.lock_tooltip_permanent', 'Corrections disabled')}"
      data-lock-state="locked-permanent">
    {icon_locked}
</span>"""
        elif show_correction == 'on_demand':
            if granularity == 'page':
                if quiz['number'] == 1:
                    unlock_html = f"""
<button class="mkq-unlock-btn locked"
        onclick="showUnlockModal('page')"
        title="{self.i18n.t('ui.lock_tooltip_page', 'Click to unlock all quizzes on this page')}"
        data-lock-state="locked">
    {icon_unlock}
</button>"""
                else:
                    unlock_html = f"""
<span class="mkq-lock-indicator locked-permanent"
      title="{self.i18n.t('ui.lock_tooltip_page_locked', 'Unlock all quizzes using the button on the first quiz')}"
      data-lock-state="locked-permanent">
    {icon_locked}
</span>"""
            elif granularity == 'question':
                unlock_html = f"""
<button class="mkq-unlock-btn locked"
        onclick="showUnlockModal('exercise', '{quiz_id}')"
        title="{self.i18n.t('ui.lock_tooltip_quiz_questions', 'Click to unlock all questions in this quiz')}"
        data-lock-state="locked">
    {icon_unlock}
</button>"""
            else:
                unlock_html = f"""
<button class="mkq-unlock-btn locked"
        onclick="showUnlockModal('exercise', '{quiz_id}')"
        title="{self.i18n.t('ui.lock_tooltip_locked', 'Click to unlock this quiz')}"
        data-lock-state="locked">
    {icon_unlock}
</button>"""
        else:
            unlock_html = f"""
<span class="mkq-lock-indicator unlocked"
      title="{self.i18n.t('ui.lock_tooltip_unlocked', 'Corrections always visible')}"
      data-lock-state="unlocked">
    {icon_unlocked}
</span>"""

        html = f"""
<div class="mkdocs-superquiz admonition {quiz_type}"
     data-quiz-id="{quiz_id}"
     data-quiz-type="{quiz_type}"
     data-page-uuid="{self.page_uuid}"
     data-randomize-answers="{str(randomize_answers).lower()}"
     data-randomize-questions="{str(randomize_questions).lower()}"
     data-encrypted="{encrypted_corrections}">

    <p class="admonition-title">
        {title}
        {unlock_html}
    </p>

    {preamble_html}

    {questions_html}

    <div class="quiz-actions">
        <button class="mkq-validate-btn" onclick="validateQuiz('{quiz_id}')">
            {self.i18n.t('ui.validate_button', 'Validate')}
        </button>
        <div class="mkq-score" id="score-{quiz_id}" style="display:none;"></div>
    </div>
</div>
"""
        return html

    # ─────────────────────────────────────────────────────────────────────────
    # Render one question
    # ─────────────────────────────────────────────────────────────────────────

    def render_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        quiz_type = quiz.get('type')
        qid = question.get('number', 0)
        is_meta = bool(question.get('is_meta')) or (qid == 0)
        if is_meta:
            return self._render_meta_preamble(question)
        if quiz_type in ('mcquiz', 'scquiz'):
            return self._render_choice_question(quiz, question, quiz_id)
        if quiz_type == 'fillblanks':
            return self._render_fillblanks_question(quiz, question, quiz_id)
        if quiz_type == 'scdropdown':
            return self._render_scdropdown_question(quiz, question, quiz_id)
        if quiz_type == 'mcdropdown':
            return self._render_mcdropdown_question(quiz, question, quiz_id)
        return ''

    # ─────────────────────────────────────────────────────────────────────────
    # Helpers
    # ─────────────────────────────────────────────────────────────────────────

    def _wrap_rich_content(self, rich_content: str) -> str:
        if not rich_content or not rich_content.strip():
            return ''
        return f'<div class="mkq-rich-content">{rich_content}</div>'

    def _render_meta_preamble(self, question: dict) -> str:
        rich_html = self._wrap_rich_content(question.get('rich_content', ''))
        if not rich_html:
            return ''
        return f'<div class="mkq-preamble mkq-meta">{rich_html}</div>'

    # ─────────────────────────────────────────────────────────────────────────
    # Choice question (mcquiz / scquiz)
    # ─────────────────────────────────────────────────────────────────────────

    def _render_choice_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        question_id = question.get('number', 0)
        input_type = 'checkbox' if quiz['type'] == 'mcquiz' else 'radio'
        raw_or_html = question.get('text_html') or question.get('text', '')
        question_text = self._render_math(raw_or_html)
        rich_html = self._wrap_rich_content(question.get('rich_content', ''))

        granularity = self.config.get('correction_granularity', 'all')
        question_unlock_html = ''
        if granularity == 'question':
            page = self.db.get_page_by_uuid(self.page_uuid) if self.db else None
            corrections_locked = page.get('corrections_locked', False) if page else False
            if corrections_locked:
                question_unlock_html = f"""
<button class="mkq-unlock-btn locked"
        onclick="showUnlockModal('question', '{quiz_id}', '{question_id}')"
        title="{self.i18n.t('ui.lock_tooltip_locked', 'Click to unlock corrections')}"
        data-lock-state="locked">
    🔒
</button>"""

        qcfg_for_rand = question.get('config') or {}
        q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)

        q_rand_attr = ''
        if q_randomize_answers in (True, 'yes', 'true', 1):
            q_rand_attr = ' data-randomize-answers="true"'
        elif q_randomize_answers in (False, 'no', 'false', 0):
            q_rand_attr = ' data-randomize-answers="false"'

        answers_html = ''
        for idx, answer in enumerate(question.get('answers', [])):
            answer_id = f"{quiz_id}-q{question_id}-a{idx}"
            answer_text = self._render_math(answer['text'])
            answers_html += f"""
<label class="mkq-answer" data-answer-text="{answer['text'].replace('"', '&quot;')}">
    <input type="{input_type}" id="{answer_id}" name="{quiz_id}-q{question_id}" value="{idx}" data-answer-index="{idx}">
    <span class="mkq-answer-text">{answer_text}</span>
    <span class="mkq-answer-feedback" style="display:none;"></span>
</label>"""

        if not rich_html or not rich_html.strip():
            return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text">
        <span class="mkq-number">{question_id}.</span> {question_text} {question_unlock_html}
    </div>
    <div class="mkq-answers">{answers_html}</div>
</div>"""

        return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text mkq-question-number-only">
        <span class="mkq-number">{question_id}.</span> {question_unlock_html}
    </div>
    {rich_html}
    <div class="mkq-question-text mkq-question-body">{question_text}</div>
    <div class="mkq-answers">{answers_html}</div>
</div>"""

    # ─────────────────────────────────────────────────────────────────────────
    # Fillblanks question
    # ─────────────────────────────────────────────────────────────────────────

    def _render_fillblanks_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        question_id = question.get('number', 0)
        question_text = question.get('text', '')
        rich_html = self._wrap_rich_content(question.get('rich_content', ''))

        blank_pattern = r'(?<!!)(?<!\()\[([^\]]+)\](?!\()(\{[^}]*\})?'

        def make_replacer(qid, qnum):
            counters = [0]
            def replacer(match):
                input_id = f"{qid}-q{qnum}-blank{counters[0]}"
                h = f'<input type="text" class="mkq-blank-input" id="{input_id}" data-blank-index="{counters[0]}">'
                counters[0] += 1
                return h
            return replacer

        blank_html = re.sub(blank_pattern, make_replacer(quiz_id, question_id), question_text)
        blank_html = self._render_math(blank_html)

        if not rich_html or not rich_html.strip():
            return f"""
<div class="mkq-question" data-question-id="{question_id}">
    <div class="mkq-question-text">
        <span class="mkq-number">{question_id}.</span> {blank_html}
    </div>
</div>"""

        return f"""
<div class="mkq-question" data-question-id="{question_id}">
    <div class="mkq-question-text mkq-question-number-only">
        <span class="mkq-number">{question_id}.</span>
    </div>
    {rich_html}
    <div class="mkq-question-text mkq-question-body">{blank_html}</div>
</div>"""

    # ─────────────────────────────────────────────────────────────────────────
    # Scdropdown question
    # ─────────────────────────────────────────────────────────────────────────

    def _render_scdropdown_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        question_id = question.get('number', 0)
        question_text = question.get('text', '')
        rich_html = self._wrap_rich_content(question.get('rich_content', ''))

        scdropdowns = question.get('scdropdowns')
        if not scdropdowns:
            dd = question.get('scdropdown')
            scdropdowns = [dd] if dd else []

        rendered_text = question_text
        for dd_index, scdropdown_data in enumerate(scdropdowns):
            if not scdropdown_data:
                continue
            scdropdown_id = f"{quiz_id}-q{question_id}-scdropdown-{dd_index}"
            scdropdown_html = (
                f'<div class="mkq-custom-scdropdown" id="{scdropdown_id}"'
                f' data-quiz-id="{quiz_id}" data-question-id="{question_id}">'
                '<button type="button" class="mkq-custom-scdropdown-btn"'
                ' onclick="window.toggleScdropdownQuiz(this); return false;">'
                '<span class="mkq-custom-scdropdown-value">--</span>'
                '<span class="mkq-custom-scdropdown-arrow">▾</span>'
                '</button>'
                '<div class="mkq-custom-scdropdown-options" style="display: none;">'
            )
            for idx, option in enumerate(scdropdown_data.get('options', [])):
                selected = 'selected' if idx == 0 else ''
                escaped_option = option.replace('"', '&quot;')
                scdropdown_html += (
                    f'<div class="mkq-custom-scdropdown-option {selected}"'
                    f' data-value="{idx}"'
                    f' data-option-text="{escaped_option}"'
                    f' onclick="window.selectScdropdownQuizOption(this); return false;">'
                    f'{option}</div>'
                )
            scdropdown_html += '</div></div>'
            placeholder = f'___SCDROPDOWN_{dd_index}___'
            if placeholder in rendered_text:
                rendered_text = rendered_text.replace(placeholder, scdropdown_html, 1)

        rendered_text = self._render_math(rendered_text)

        qcfg_for_rand = question.get('config') or {}
        q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)
        q_rand_attr = ''
        if q_randomize_answers in (True, 'yes', 'true', 1):
            q_rand_attr = ' data-randomize-answers="true"'
        elif q_randomize_answers in (False, 'no', 'false', 0):
            q_rand_attr = ' data-randomize-answers="false"'

        if not rich_html or not rich_html.strip():
            return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text">
        <span class="mkq-number">{question_id}.</span> {rendered_text}
    </div>
</div>"""

        return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text mkq-question-number-only">
        <span class="mkq-number">{question_id}.</span>
    </div>
    {rich_html}
    <div class="mkq-question-text mkq-question-body">{rendered_text}</div>
</div>"""

    # ─────────────────────────────────────────────────────────────────────────
    # Mcdropdown question
    # ─────────────────────────────────────────────────────────────────────────

    def _render_mcdropdown_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        question_id = question.get('number', 0)
        question_text = question.get('text', '')
        rich_html = self._wrap_rich_content(question.get('rich_content', ''))

        mcdropdowns = question.get('mcdropdowns', [])

        rendered_text = question_text
        for dd_index, dd_data in enumerate(mcdropdowns):
            if not dd_data:
                continue
            dd_id = f"{quiz_id}-q{question_id}-mcdropdown-{dd_index}"
            # Bouton fermé : label vide par défaut (sera mis à jour par JS)
            dd_html = (
                f'<div class="mkq-custom-mcdropdown" id="{dd_id}"'
                f' data-quiz-id="{quiz_id}" data-question-id="{question_id}"'
                f' data-selected-indices="[]">'
                '<button type="button" class="mkq-custom-mcdropdown-btn"'
                ' onclick="window.toggleMcdropdownPanel(this); return false;">'
                '<span class="mkq-custom-mcdropdown-value">--</span>'
                '<span class="mkq-custom-mcdropdown-arrow">▾</span>'
                '</button>'
                '<div class="mkq-custom-mcdropdown-options" style="display: none;">'
            )
            for idx, option in enumerate(dd_data.get('options', [])):
                escaped_option = option.replace('"', '&quot;')
                dd_html += (
                    f'<div class="mkq-custom-mcdropdown-option"'
                    f' data-value="{idx}"'
                    f' data-option-text="{escaped_option}"'
                    f' onclick="window.toggleMcdropdownOption(this); return false;">'
                    f'{option}</div>'
                )
            dd_html += '</div></div>'

            placeholder = f'___MCDROPDOWN_{dd_index}___'
            if placeholder in rendered_text:
                rendered_text = rendered_text.replace(placeholder, dd_html, 1)

        rendered_text = self._render_math(rendered_text)

        qcfg_for_rand = question.get('config') or {}
        q_randomize_answers = qcfg_for_rand.get('randomize_answers', None)
        q_rand_attr = ''
        if q_randomize_answers in (True, 'yes', 'true', 1):
            q_rand_attr = ' data-randomize-answers="true"'
        elif q_randomize_answers in (False, 'no', 'false', 0):
            q_rand_attr = ' data-randomize-answers="false"'

        if not rich_html or not rich_html.strip():
            return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text">
        <span class="mkq-number">{question_id}.</span> {rendered_text}
    </div>
</div>"""

        return f"""
<div class="mkq-question" data-question-id="{question_id}"{q_rand_attr}>
    <div class="mkq-question-text mkq-question-number-only">
        <span class="mkq-number">{question_id}.</span>
    </div>
    {rich_html}
    <div class="mkq-question-text mkq-question-body">{rendered_text}</div>
</div>"""

    # ─────────────────────────────────────────────────────────────────────────
    # Math rendering
    # ─────────────────────────────────────────────────────────────────────────

    def _render_math(self, text: str) -> str:
        def protect_html(match):
            return match.group(0).replace('$', '&#36;')
        text = re.sub(r'<[^>]+>', protect_html, text)
        text = re.sub(r'\$\$(.*?)\$\$', lambda m: f'\\[{m.group(1)}\\]', text, flags=re.DOTALL)
        text = re.sub(r'(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)', lambda m: f'\\({m.group(1)}\\)', text)
        text = text.replace('&#36;', '$')
        return text

    # ─────────────────────────────────────────────────────────────────────────
    # Correction data helpers
    # ─────────────────────────────────────────────────────────────────────────

    def _resolve_question_points(self, quiz_type: str, question: dict) -> float:
        qcfg = question.get('config') or {}
        if isinstance(qcfg, dict) and 'points' in qcfg and qcfg['points'] is not None:
            try:
                return float(qcfg['points'])
            except Exception:
                pass
        if question.get('points') is not None:
            try:
                return float(question['points'])
            except Exception:
                pass
        qt_cfg = self.config.get(quiz_type, {}) if isinstance(self.config, dict) else {}
        try:
            return float(qt_cfg.get('default_points', 1))
        except Exception:
            return 1.0

    def _get_compare_answers_for_quiz(self, quiz: dict) -> dict:
        import copy
        gcfg = self.config.get('fillblanks', {}) if isinstance(self.config, dict) else {}
        global_raw = gcfg.get('compare_answers')
        base = normalize_compare_answers(global_raw if isinstance(global_raw, dict) else {})
        quiz_cfg = quiz.get('config') or {}
        quiz_raw = quiz_cfg.get('compare_answers') or quiz_cfg.get('match') \
            if isinstance(quiz_cfg, dict) else None
        if not quiz_raw or not isinstance(quiz_raw, dict):
            return base
        quiz_norm = normalize_compare_answers(quiz_raw)
        merged = copy.deepcopy(base)
        merged['ignore'].update(quiz_norm['ignore'])
        merged['equivalence'].update(quiz_norm['equivalence'])
        return merged

    def _get_compare_answers_for_question(self, question: dict, quiz_compare: dict) -> Optional[dict]:
        import copy
        qcfg = question.get('config') or {}
        if not isinstance(qcfg, dict):
            return None
        raw = qcfg.get('compare_answers') or qcfg.get('match')
        if not raw or not isinstance(raw, dict):
            return None
        merged = copy.deepcopy(quiz_compare)
        raw_norm = normalize_compare_answers(raw)
        merged['ignore'].update(raw_norm['ignore'])
        merged['equivalence'].update(raw_norm['equivalence'])
        return merged

    def _normalize_errors_mode_choice(self, question: dict, quiz_cfg: dict, qcfg: dict = None) -> dict:
        RESERVED_KEYS = ('default_false', 'default', 'min', 'max')

        if qcfg is None:
            raw_qcfg = question.get('config') or {}
            qcfg = _flatten_score_block_static(raw_qcfg) if isinstance(raw_qcfg, dict) else {}

        def _extract_rules(em_dict: dict) -> list:
            rules = []
            if not isinstance(em_dict, dict):
                return rules
            for k, v in em_dict.items():
                if k in RESERVED_KEYS:
                    continue
                score_val = _coerce_float(v)
                if score_val is not None:
                    rules.append({'key': str(k), 'score': score_val})
            return rules

        result = {'rules': [], 'default_false': None}

        qcfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
        q_em = question.get('errors_mode')
        em = qcfg_em if isinstance(qcfg_em, dict) else (q_em if isinstance(q_em, dict) else None)

        if isinstance(em, dict):
            result['rules'] = _extract_rules(em)
            df = em.get('default_false') if 'default_false' in em else em.get('default')
            result['default_false'] = _coerce_float(df)

        return result

    def _normalize_errors_mode_fillblanks(self, question: dict) -> dict:
        out = {'blanks': []}
        qcfg = question.get('config') or {}
        cfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
        cfg_em = cfg_em if isinstance(cfg_em, dict) else {}

        for i, blank in enumerate(question.get('blanks', [])):
            em = blank.get('errors_mode')
            if not isinstance(em, dict):
                em = cfg_em.get(f'blank{i}')
            if not isinstance(em, dict):
                em = cfg_em.get(i)
            if not isinstance(em, dict):
                em = cfg_em.get(str(i))

            obj = {'default_false': None, 'rules': []}
            if isinstance(em, dict):
                if 'default_false' in em:
                    obj['default_false'] = _coerce_float(em.get('default_false'))
                elif 'default' in em:
                    obj['default_false'] = _coerce_float(em.get('default'))
                for k, v in em.items():
                    if k in ('default_false', 'default'):
                        continue
                    sc = _coerce_float(v)
                    if sc is None:
                        continue
                    obj['rules'].append({'answer': str(k), 'score': sc})
            out['blanks'].append(obj)
        return out

    def _normalize_errors_mode_scdropdown(self, question: dict) -> dict:
        out = {'scdropdowns': []}
        scdropdowns = question.get('scdropdowns') or []
        if not scdropdowns:
            dd = question.get('scdropdown')
            scdropdowns = [dd] if dd else []

        qcfg = question.get('config') or {}
        cfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
        cfg_em = cfg_em if isinstance(cfg_em, dict) else {}

        for i, dd in enumerate(scdropdowns):
            em = dd.get('errors_mode') if isinstance(dd, dict) else None
            if not isinstance(em, dict):
                em = cfg_em.get(f'scdropdown{i}')
            if not isinstance(em, dict):
                em = cfg_em.get(i)
            if not isinstance(em, dict):
                em = cfg_em.get(str(i))

            obj = {'default_false': None, 'rules': []}
            if isinstance(em, dict):
                if 'default_false' in em:
                    obj['default_false'] = _coerce_float(em.get('default_false'))
                elif 'default' in em:
                    obj['default_false'] = _coerce_float(em.get('default'))
                for k, v in em.items():
                    if k in ('default_false', 'default'):
                        continue
                    sc = _coerce_float(v)
                    if sc is None:
                        continue
                    obj['rules'].append({'option': str(k), 'score': sc})
            out['scdropdowns'].append(obj)
        return out

    def _normalize_errors_mode_mcdropdown(self, question: dict, quiz_cfg: dict = None, qt_cfg: dict = None) -> dict:
        """
        Construit l'errors_mode pour chaque dropdown d'une question mcdropdown.

        Chaîne de priorité pour default_false (du plus prioritaire au moins) :
          1. errors_mode inline du dropdown lui-même  (dd['errors_mode']['default_false'])
          2. errors_mode du front-matter question      (qcfg['errors_mode'][i]['default_false'])
          3. errors_mode du front-matter quiz          (quiz_cfg['errors_mode']['default_false'])
          4. default_false du front-matter quiz        (quiz_cfg['default_false'])
          5. default_false du type de quiz             (qt_cfg['default_false'])
          6. None  (pas de pénalité)

        Les règles bracket-list suivent la même priorité 1 > 2.
        """
        out = {'mcdropdowns': []}
        mcdropdowns = question.get('mcdropdowns', [])

        # Niveau question (front-matter de la question)
        qcfg = question.get('config') or {}
        if not isinstance(qcfg, dict):
            qcfg = {}
        q_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
        q_em = q_em if isinstance(q_em, dict) else {}

        # Niveau quiz (front-matter du quiz)
        if not isinstance(quiz_cfg, dict):
            quiz_cfg = {}
        quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
        quiz_em = quiz_em if isinstance(quiz_em, dict) else {}

        # default_false au niveau quiz (hors errors_mode, directement dans quiz_cfg)
        quiz_df = None
        if 'default_false' in quiz_cfg:
            quiz_df = _coerce_float(quiz_cfg.get('default_false'))
        elif 'default_false' in quiz_em:
            quiz_df = _coerce_float(quiz_em.get('default_false'))
        elif 'default' in quiz_em:
            quiz_df = _coerce_float(quiz_em.get('default'))

        # default_false au niveau type de quiz (config globale)
        qt_df = None
        if isinstance(qt_cfg, dict):
            if 'default_false' in qt_cfg:
                qt_df = _coerce_float(qt_cfg.get('default_false'))

        for i, dd in enumerate(mcdropdowns):
            # 1. errors_mode inline du dropdown
            em_inline = dd.get('errors_mode') if isinstance(dd, dict) else None

            # 2. errors_mode du front-matter question (par index)
            em_q = None
            if isinstance(q_em, dict):
                em_q = q_em.get(f'mcdropdown{i}') or q_em.get(i) or q_em.get(str(i))
            em_q = em_q if isinstance(em_q, dict) else None

            obj = {'default_false': None, 'rules': []}

            # --- Règles (bracket-list) : inline > question-level ---
            em_for_rules = em_inline if isinstance(em_inline, dict) else em_q
            if isinstance(em_for_rules, dict):
                for k, v in em_for_rules.items():
                    if k in ('default_false', 'default'):
                        continue
                    sc = _coerce_float(v)
                    if sc is None:
                        continue
                    obj['rules'].append({'key': str(k), 'score': sc})

            # --- default_false : chaîne de fallback ---
            df = None

            # Priorité 1 : inline dd
            if isinstance(em_inline, dict):
                if 'default_false' in em_inline:
                    df = _coerce_float(em_inline['default_false'])
                elif 'default' in em_inline:
                    df = _coerce_float(em_inline['default'])

            # Priorité 2 : front-matter question (par index)
            if df is None and isinstance(em_q, dict):
                if 'default_false' in em_q:
                    df = _coerce_float(em_q['default_false'])
                elif 'default' in em_q:
                    df = _coerce_float(em_q['default'])

            # Priorité 3 & 4 : quiz-level (errors_mode ou direct)
            if df is None:
                df = quiz_df

            # Priorité 5 : type-level
            if df is None:
                df = qt_df

            obj['default_false'] = df
            out['mcdropdowns'].append(obj)

        return out

    def _prepare_correction_data(self, quiz: dict, quiz_id: str) -> dict:
        quiz_type = quiz.get('type')
        qt_cfg = self.config.get(quiz_type, {}) if isinstance(self.config, dict) else {}

        quiz_cfg = quiz.get('config') or {}
        if not isinstance(quiz_cfg, dict):
            quiz_cfg = {}

        quiz_cfg = _flatten_score_block(quiz_cfg)

        scoring_mode = quiz_cfg.get('scoring_mode') or qt_cfg.get('scoring_mode') or 'all_or_nothing'
        default_false = qt_cfg.get('default_false', qt_cfg.get('default', 0))
        try:
            default_false = float(default_false)
        except Exception:
            default_false = 0.0

        _SENTINEL = object()

        def _resolve_min(raw):
            if raw is None:
                return None
            if isinstance(raw, str) and raw.strip().lower() == 'none':
                return None
            return _coerce_float(raw)

        quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
        if isinstance(quiz_em, dict) and 'min' in quiz_em:
            min_score = _resolve_min(quiz_em['min'])
        else:
            _ms = qt_cfg.get('min_score', _SENTINEL)
            if _ms is _SENTINEL:
                _ms = self.config.get('min_score', _SENTINEL)
            min_score = None if _ms is _SENTINEL else _ms

        # default_false au niveau quiz (front-matter quiz) — priorité sur qt_cfg
        _quiz_df_raw = None
        if isinstance(quiz_em, dict):
            if 'default_false' in quiz_em:
                _quiz_df_raw = quiz_em['default_false']
            elif 'default' in quiz_em:
                _quiz_df_raw = quiz_em['default']
        elif 'default_false' in quiz_cfg:
            _quiz_df_raw = quiz_cfg['default_false']
        quiz_default_false = _coerce_float(_quiz_df_raw) if _quiz_df_raw is not None else None

        clamp_max = bool(self.config.get('clamp_max_score', True))

        _save = quiz_cfg.get('save_answers', True)
        if isinstance(_save, str):
            _save = _save.lower() not in ('no', 'false', '0')
        save_answers = bool(_save)

        quiz_compare = self._get_compare_answers_for_quiz(quiz) if quiz_type == 'fillblanks' else None

        correction_data: Dict[str, Any] = {
            'quiz_id':        quiz['number'],
            'quiz_dom_id':    quiz_id,
            'quiz_type':      quiz_type,
            'scoring_mode':   scoring_mode,
            'default_false':  default_false,
            'min_score':      min_score,
            'clamp_max_score': clamp_max,
            'save_answers':   save_answers,
            'compare_answers': quiz_compare,
            'questions':      []
        }

        for question in quiz.get('questions', []):
            if question.get('is_meta') or question.get('number') == 0:
                continue

            points = self._resolve_question_points(quiz_type, question)

            raw_qcfg = question.get('config') or {}
            if not isinstance(raw_qcfg, dict):
                raw_qcfg = {}
            qcfg = _flatten_score_block(raw_qcfg)

            q_default_false = None
            if 'default_false' in qcfg:
                q_default_false = _coerce_float(qcfg.get('default_false'))
            elif 'default' in qcfg:
                q_default_false = _coerce_float(qcfg.get('default'))

            q_min_score = min_score

            quiz_em = quiz_cfg.get('errors_mode') if isinstance(quiz_cfg, dict) else None
            if isinstance(quiz_em, dict) and 'min' in quiz_em:
                q_min_score = _resolve_min(quiz_em['min'])

            qcfg_em = qcfg.get('errors_mode') if isinstance(qcfg, dict) else None
            if isinstance(qcfg_em, dict) and 'min' in qcfg_em:
                q_min_score = _resolve_min(qcfg_em['min'])

            if 'min' in qcfg:
                q_min_score = _resolve_min(qcfg.get('min'))
            elif 'min_score' in qcfg:
                q_min_score = _resolve_min(qcfg.get('min_score'))

            question_data: Dict[str, Any] = {
                'number':        question['number'],
                'text':          question.get('text', ''),
                'points':        points,
                'scoring_mode':  qcfg.get('scoring_mode') or scoring_mode,
                'default_false': default_false,
                'quiz_default_false': quiz_default_false,
                'question_default_false': q_default_false,
                'min_score':     q_min_score,
                'clamp_max_score': clamp_max,
            }

            if quiz_type in ['mcquiz', 'scquiz']:
                question_data['answers_text'] = [a.get('text', '') for a in question.get('answers', [])]
                question_data['correct_answers'] = [
                    idx for idx, answer in enumerate(question.get('answers', []))
                    if answer.get('is_correct')
                ]
                question_data['correct_answers_text'] = [
                    answer.get('text', '') for answer in question.get('answers', [])
                    if answer.get('is_correct')
                ]
                question_data['errors_mode'] = self._normalize_errors_mode_choice(question, quiz_cfg, qcfg)

            elif quiz_type == 'fillblanks':
                question_data['blanks'] = question.get('blanks', [])
                question_data['errors_mode'] = self._normalize_errors_mode_fillblanks(question)
                q_compare = self._get_compare_answers_for_question(question, quiz_compare)
                if q_compare is not None:
                    question_data['compare_answers'] = q_compare

            elif quiz_type == 'scdropdown':
                scdropdowns = question.get('scdropdowns')
                question_data['scdropdowns'] = scdropdowns if scdropdowns else [question.get('scdropdown', {})]
                question_data['errors_mode'] = self._normalize_errors_mode_scdropdown(question)

            elif quiz_type == 'mcdropdown':
                mcdropdowns = question.get('mcdropdowns', [])
                question_data['mcdropdowns'] = mcdropdowns
                question_data['errors_mode'] = self._normalize_errors_mode_mcdropdown(
                    question, quiz_cfg=quiz_cfg, qt_cfg=qt_cfg
                )

            correction_data['questions'].append(question_data)

        return correction_data

    def _encrypt_corrections(self, correction_data: dict) -> str:
        key = self.config.get('encryption_key', 'default-key-change-me')
        json_str = json.dumps(correction_data)
        key_bytes = key.encode('utf-8')
        data_bytes = json_str.encode('utf-8')
        encrypted = bytearray(byte ^ key_bytes[i % len(key_bytes)] for i, byte in enumerate(data_bytes))
        return base64.b64encode(bytes(encrypted)).decode('utf-8')