"""
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)

        # ── Résolution randomize au niveau QUIZ uniquement ────────────────────
        # On lit UNIQUEMENT depuis :
        #   1. front-matter PAR-QUIZ (_quiz_fm) — ex: randomize: answers dans le ---
        #   2. config du type (qt_cfg) — ex: mcquiz: randomize: answers dans mkdocs.yml
        #   3. config top-level (_cfg) — ex: randomize: answers au top dans mkdocs.yml
        # Les front-matters PAR-QUESTION (q['config']) ne remontent JAMAIS ici.
        quiz_type_config = _flatten_score_block(
            self.config.get(quiz_type, {}) if isinstance(self.config, dict) else {}
        )
        _cfg = self.config if isinstance(self.config, dict) else {}
        _quiz_fm = _flatten_score_block(quiz.get('config') or {})

        def _resolve(key, default=None):
            v = _quiz_fm.get(key)
            if v is not None: return v
            v = quiz_type_config.get(key)
            if v is not None: return v
            v = _cfg.get(key)
            if v is not None: return v
            return default

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

        # randomize_answers/randomize_questions au niveau quiz viennent de _resolve,
        # qui ne lit QUE _quiz_fm, qt_cfg, _cfg — jamais les q['config'] individuels.
        quiz_randomize_answers   = bool(_resolve('randomize_answers',  False))
        quiz_randomize_questions = bool(_resolve('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(quiz_randomize_answers).lower()}"
     data-randomize-questions="{str(quiz_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)
        if quiz_type == 'orderquiz':
            return self._render_order_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)

        # Toujours émettre l'attribut sur chaque question.
        # "inherit" signale au JS d'utiliser la valeur du <div> quiz parent.
        # Cela empêche la valeur quiz-level de se propager à des questions
        # qui n'ont pas de randomize: answers dans leur propre config.
        if q_randomize_answers is None:
            q_rand_attr = ' data-randomize-answers="inherit"'
        elif q_randomize_answers in (True, 'yes', 'true', 1):
            q_rand_attr = ' data-randomize-answers="true"'
        else:
            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>"""

        # Le texte de la question est TOUJOURS inline avec le numéro.
        # Le rich_content (image, iframe, etc.) se place en dessous si présent.
        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>
    {rich_html}
    <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 {}
        # qt_cfg peut contenir score: {mode: ...} venant du front-matter page —
        # il faut l'aplatir pour que scoring_mode soit accessible directement.
        qt_cfg = _flatten_score_block(qt_cfg) if isinstance(qt_cfg, dict) else {}

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

        quiz_cfg = _flatten_score_block(quiz_cfg)

        # Chaîne de priorité scoring_mode :
        # quiz_cfg (front-matter quiz) > qt_cfg (sous-dict type, ex: mcquiz:) >
        # self.config top-level (front-matter page, ex: score: mode:) > défaut
        scoring_mode = (
            quiz_cfg.get('scoring_mode')
            or qt_cfg.get('scoring_mode')
            or (self.config.get('scoring_mode') if isinstance(self.config, dict) else None)
            or 'all_or_nothing'
        )
        # default_false : qt_cfg > self.config top-level > 0
        _df_raw = (
            qt_cfg.get('default_false')
            if qt_cfg.get('default_false') is not None
            else qt_cfg.get('default',
                (self.config.get('default_false') if isinstance(self.config, dict) else None)
            )
        )
        if _df_raw is None and isinstance(self.config, dict):
            _df_raw = self.config.get('default_false', self.config.get('default', 0))
        try:
            default_false = float(_df_raw if _df_raw is not None else 0)
        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(
            quiz_cfg.get('clamp_max_score',
                qt_cfg.get('clamp_max_score',
                    self.config.get('clamp_max_score', True)))
        )

        # save_answers : quiz_cfg > qt_cfg (page) > défaut True
        _save_raw = quiz_cfg.get('save_answers')
        if _save_raw is None:
            _save_raw = qt_cfg.get('save_answers')
        if _save_raw is None:
            _save_raw = True
        if isinstance(_save_raw, str):
            _save_raw = _save_raw.lower() not in ('no', 'false', '0')
        save_answers = bool(_save_raw)

        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
                )

            elif quiz_type == 'orderquiz':
                question_data['items'] = [item for item in question.get('items', [])]
                question_data['scoring_mode'] = qcfg.get('scoring_mode') or scoring_mode or 'proportional'

            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')

    # ─────────────────────────────────────────────────────────────────────────
    # Orderquiz question
    # ─────────────────────────────────────────────────────────────────────────

    def _render_order_question(self, quiz: dict, question: dict, quiz_id: str) -> str:
        question_id = question.get('number', 0)
        items = question.get('items', [])
        if not items:
            return ''

        # Les items sont rendus dans un ordre ALÉATOIRE côté HTML ;
        # l'ordre correct est encodé chiffré dans data-encrypted du quiz parent.
        # On émet les items dans leur ordre original ici (le JS les mélangera
        # via applyRandomization, après MathJax typesets).
        items_html = ''
        for idx, item_text in enumerate(items):
            rendered = self._render_math(item_text)
            items_html += f"""
<div class="mkq-order-item"
     draggable="true"
     data-original-index="{idx}"
     data-item-text="{item_text.replace('"', '&quot;')}">
    <span class="mkq-order-handle">⠿</span>
    <span class="mkq-order-text">{rendered}</span>
</div>"""

        return f"""
<div class="mkq-question mkq-order-question" data-question-id="{question_id}">
    <div class="mkq-order-items" id="{quiz_id}-q{question_id}-items">
        {items_html}
    </div>
</div>"""