"""
Parser pour extraire les quiz du Markdown
Supports rich content (images, iframes, markdown, html) in questions/preambles.

i18n: config keys and values in mini front-matter are resolved via QuizI18n
before being stored. This means you can write:

    ---
    mode_evaluation: proportionnel   # fr
    afficher_correction: oui         # fr
    ---

and it will be stored as:

    { "scoring_mode": "proportional", "show_correction": "yes" }

English keywords always work as a fallback regardless of configured language.
"""

import re
from typing import Any, Dict, List, Tuple, Optional


# ─────────────────────────────────────────────────────────────────────────────
# Admonition body-line detector  (shared rule — must match plugin.py exactly)
# ─────────────────────────────────────────────────────────────────────────────

_HTML_BLOCK_TAG_RE = re.compile(
    r'^<(?:iframe|div|figure|figcaption|table|thead|tbody|tr|th|td|'
    r'blockquote|pre|video|audio|script|style|details|summary)\b',
    re.IGNORECASE
)


def _is_admonition_body_line(line: str) -> bool:
    return (
        line == '' or
        line.startswith('    ') or
        line.startswith('\t') or
        bool(_HTML_BLOCK_TAG_RE.match(line))
    )


# ─────────────────────────────────────────────────────────────────────────────
# Mini YAML-ish parser
# ─────────────────────────────────────────────────────────────────────────────

def _coerce_scalar(value: str) -> Any:
    v = value.strip()
    if v == '':
        return ''
    low = v.lower()
    if low in ('true', 'yes', 'on'):
        return True
    if low in ('false', 'no', 'off'):
        return False
    if low in ('null', 'none', '~'):
        return None
    if re.fullmatch(r'[+-]?\d+', v):
        try:
            return int(v)
        except Exception:
            pass
    if re.fullmatch(r'[+-]?\d+\.\d+', v):
        try:
            return float(v)
        except Exception:
            pass
    if (len(v) >= 2) and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
        return v[1:-1]
    return v


def _parse_mini_yaml(lines: List[str]) -> Dict[str, Any]:
    """
    Parse a mini YAML block into a raw dict (no i18n resolution at this stage).
    i18n resolution is applied by the caller via QuizI18n.normalize_config_dict().
    """
    root: Dict[str, Any] = {}
    stack: List[Tuple[int, Dict[str, Any]]] = [(0, root)]

    for raw in lines:
        if not raw.strip():
            continue
        line = raw.rstrip()
        indent = len(line) - len(line.lstrip(' '))
        s = line.strip()

        m = re.match(r'^([^:#]+)\s*:\s*(.*)$', s)
        if not m:
            continue

        key = m.group(1).strip()
        val = m.group(2)

        while stack and indent < stack[-1][0]:
            stack.pop()
        if not stack:
            stack = [(0, root)]

        parent = stack[-1][1]

        if val.strip() == '':
            new_map: Dict[str, Any] = {}
            parent[key] = new_map
            stack.append((indent + 2, new_map))
        else:
            parent[key] = _coerce_scalar(val)

    return root


def _strip_common_indent(lines: List[str]) -> List[str]:
    indents = []
    for ln in lines:
        if ln.strip() and ln.startswith(' '):
            indents.append(len(ln) - len(ln.lstrip(' ')))
    if not indents:
        return lines[:]
    cut = min(indents)
    out = []
    for ln in lines:
        if ln.startswith(' ' * cut):
            out.append(ln[cut:])
        else:
            out.append(ln)
    return out


# ─────────────────────────────────────────────────────────────────────────────
# Inline "{...}" parsing helpers
# ─────────────────────────────────────────────────────────────────────────────

def _split_top_level_commas(s: str) -> List[str]:
    parts = []
    cur = []
    depth = 0
    in_quote = None
    i = 0
    while i < len(s):
        ch = s[i]
        if ch in ('"', "'"):
            if in_quote == ch:
                in_quote = None
            elif in_quote is None:
                in_quote = ch
            cur.append(ch)
            i += 1
            continue
        if in_quote is not None:
            cur.append(ch)
            i += 1
            continue
        if ch == '[':
            depth += 1
            cur.append(ch)
            i += 1
            continue
        if ch == ']':
            depth = max(0, depth - 1)
            cur.append(ch)
            i += 1
            continue
        if ch == ',' and depth == 0:
            parts.append(''.join(cur).strip())
            cur = []
            i += 1
            continue
        cur.append(ch)
        i += 1
    tail = ''.join(cur).strip()
    if tail:
        parts.append(tail)
    return parts


def _parse_inline_braces_dict(line: str) -> Optional[Dict[str, Any]]:
    s = line.strip()
    if not (s.startswith('{') and s.endswith('}')):
        return None
    inner = s[1:-1].strip()
    if not inner:
        return {}
    out: Dict[str, Any] = {}
    for item in _split_top_level_commas(inner):
        if not item:
            continue
        if ':' not in item:
            continue
        k, v = item.split(':', 1)
        key = k.strip()
        val_raw = v.strip()
        try:
            val = float(val_raw)
            if val.is_integer():
                val = int(val)
        except Exception:
            val = _coerce_scalar(val_raw)
        out[key] = val
    return out


def _flatten_score_block_static(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 _parse_bracket_list_key(key: str) -> Optional[List[str]]:
    s = key.strip()
    if not (s.startswith('[') and s.endswith(']')):
        return None
    inner = s[1:-1].strip()
    if not inner:
        return []
    items = [x.strip() for x in inner.split(',')]
    items = [x for x in items if x]
    return items


# ─────────────────────────────────────────────────────────────────────────────
# Parser
# ─────────────────────────────────────────────────────────────────────────────

class QuizParser:
    def __init__(self, i18n, language: str = 'en'):
        self.i18n = i18n
        self.language = language
        self.keywords = i18n.get_parser_keywords()

    def _normalize_cfg(self, raw: Dict[str, Any]) -> Dict[str, Any]:
        return self.i18n.normalize_config_dict(raw)

    def _normalize_inline_bdict(self, bdict: Dict[str, Any]) -> Dict[str, Any]:
        out = {}
        for k, v in bdict.items():
            canon_k = self.i18n.resolve_config_key(k)
            out[canon_k] = v
        return out

    def parse_markdown(self, markdown: str) -> list:
        all_synonyms = self.i18n.get_all_quiz_type_synonyms()
        synonym_set = {s.lower() for s in all_synonyms}
        header_re = re.compile(r'^!!!\s+(\S+)\s+"([^"]+)"\s*$', re.IGNORECASE)

        src_lines = markdown.split('\n')
        quizzes = []
        quiz_number = 1
        i = 0

        while i < len(src_lines):
            line = src_lines[i]
            hm = header_re.match(line)

            if hm and hm.group(1).lower() in synonym_set:
                original_synonym = hm.group(1).lower()
                title = hm.group(2)

                i += 1
                body_lines = []
                while i < len(src_lines) and _is_admonition_body_line(src_lines[i]):
                    body_lines.append(src_lines[i])
                    i += 1
                content = '\n'.join(body_lines)

                quiz_type = self.i18n.get_quiz_type(original_synonym)
                if not quiz_type:
                    continue

                if quiz_type in ('mcquiz', 'scquiz'):
                    quiz = self._parse_choice_quiz(content, quiz_type, title, quiz_number)
                elif quiz_type == 'fillblanks':
                    quiz = self._parse_fillblanks(content, 'fillblanks', title, quiz_number)
                elif quiz_type == 'scdropdown':
                    quiz = self._parse_scdropdown(content, 'scdropdown', title, quiz_number)
                elif quiz_type == 'mcdropdown':
                    quiz = self._parse_mcdropdown(content, 'mcdropdown', title, quiz_number)
                elif quiz_type == 'orderquiz':
                    quiz = self._parse_orderquiz(content, 'orderquiz', title, quiz_number)
                else:
                    continue

                quiz['original_synonym'] = original_synonym
                quizzes.append(quiz)
                quiz_number += 1
            else:
                i += 1

        return quizzes

    def _build_keyword_patterns(self):
        preamble_keywords = self.keywords.get('preamble', ['preamble', 'pre'])
        question_keywords = self.keywords.get('question', ['question', 'q'])
        answers_keywords  = self.keywords.get('answers',  ['answers', 'ans'])

        pre_patterns = sorted(set(preamble_keywords), key=len, reverse=True)
        q_patterns   = sorted(set(question_keywords), key=len, reverse=True)

        expanded_answers = set(answers_keywords)
        for kw in list(answers_keywords):
            if kw.endswith('s') and len(kw) > 3:
                expanded_answers.add(kw[:-1])
        a_patterns = sorted(expanded_answers, key=len, reverse=True)

        p_pattern = '|'.join(re.escape(kw) for kw in pre_patterns)
        q_pattern = '|'.join(re.escape(kw) for kw in q_patterns)
        a_pattern = '|'.join(re.escape(kw) for kw in a_patterns)

        return p_pattern, q_pattern, a_pattern

    def _extract_exercise_config(self, content: str) -> Tuple[Dict[str, Any], str]:
        lines = content.split('\n')

        start = 0
        while start < len(lines) and not lines[start].strip():
            start += 1
        if start >= len(lines):
            return {}, content

        if lines[start].strip() != '---':
            return {}, content

        end = start + 1
        while end < len(lines) and lines[end].strip() != '---':
            end += 1
        if end >= len(lines):
            return {}, content

        raw_cfg_lines = lines[start + 1:end]
        raw_cfg_lines = _strip_common_indent(raw_cfg_lines)
        raw_cfg = _parse_mini_yaml(raw_cfg_lines)
        cfg = self._normalize_cfg(raw_cfg)

        remaining = lines[:start] + lines[end + 1:]
        return cfg, '\n'.join(remaining)

    @staticmethod
    def _trim_blank_edges(lines: list) -> list:
        while lines and not lines[0].strip():
            lines.pop(0)
        while lines and not lines[-1].strip():
            lines.pop()
        return lines

    def _normalize_explicit_preamble_lines(self, preamble_lines: list, p_pattern: str) -> list:
        if not preamble_lines:
            return preamble_lines

        first_idx = None
        for i, ln in enumerate(preamble_lines):
            if ln.strip():
                first_idx = i
                break
        if first_idx is None:
            return preamble_lines

        first_line = preamble_lines[first_idx]
        first_stripped = first_line.strip()

        m = re.match(rf'^({p_pattern})\s*:\s*(.*)$', first_stripped, re.IGNORECASE)
        if not m:
            return preamble_lines

        inline = (m.group(2) or '').rstrip()
        base_indent = len(first_line) - len(first_line.lstrip(' '))

        out: List[str] = []
        out.extend(preamble_lines[:first_idx])

        if inline:
            out.append((' ' * base_indent) + inline)

        for ln in preamble_lines[first_idx + 1:]:
            if not ln.strip():
                out.append('')
                continue
            if ln.startswith(' '):
                cur_indent = len(ln) - len(ln.lstrip(' '))
                if cur_indent >= base_indent + 4:
                    out.append(ln[4:])
                else:
                    out.append(ln)
            else:
                out.append(ln)

        return out

    def _split_preamble_and_body(self, content: str, p_pattern: str, q_pattern: str) -> Tuple[List[str], List[str]]:
        lines = content.split('\n')
        preamble_lines: List[str] = []
        idx = 0

        q_re = re.compile(rf'^({q_pattern})(\d*)\s*(\{{[^}}]*\}})?\s*:\s*', re.IGNORECASE)

        while idx < len(lines):
            stripped = lines[idx].strip()
            if q_re.match(stripped):
                break
            preamble_lines.append(lines[idx])
            idx += 1

        preamble_lines = self._normalize_explicit_preamble_lines(preamble_lines, p_pattern)
        preamble_lines = self._trim_blank_edges(preamble_lines)

        remaining_lines = lines[idx:]
        return preamble_lines, remaining_lines

    def _inject_meta_preamble_question(self, questions: list, preamble_lines: list) -> list:
        if not preamble_lines:
            return questions
        meta = {
            'number': 0,
            'text': '',
            'rich_content': '\n'.join(preamble_lines),
            'answers': [],
            'is_meta': True,
        }
        return [meta] + questions

    def _parse_points_brace(self, brace: Optional[str]) -> Optional[float]:
        if not brace:
            return None
        s = brace.strip()
        if not (s.startswith('{') and s.endswith('}')):
            return None
        inner = s[1:-1].strip()
        if not inner:
            return None
        try:
            return float(inner)
        except Exception:
            return None

    # ─────────────────────────────────────────────────────────────────────────
    # mcquiz / scquiz
    # ─────────────────────────────────────────────────────────────────────────

    def _parse_choice_quiz(self, content: str, quiz_type: str, title: str, number: int) -> dict:
        exercise_cfg, content2 = self._extract_exercise_config(content)

        p_pattern, q_pattern, a_pattern = self._build_keyword_patterns()
        preamble_lines, remaining_lines = self._split_preamble_and_body(content2, p_pattern, q_pattern)

        q_re = re.compile(rf'^({q_pattern})(\d*)\s*(\{{[^}}]*\}})?\s*:\s*(.*)$', re.IGNORECASE)
        a_re = re.compile(rf'^({a_pattern})\d*\s*:\s*$', re.IGNORECASE)

        questions = []
        current_question = None
        question_number = 1
        state = 'scan'

        pending_answers_cfg: Dict[str, Any] = {}
        pending_answers_started = False
        answers_cfg_collecting = False
        answers_cfg_lines: List[str] = []

        def flush_answers_cfg():
            nonlocal pending_answers_cfg, answers_cfg_collecting, answers_cfg_lines, current_question

            if answers_cfg_lines:
                raw = _strip_common_indent(answers_cfg_lines)
                raw_cfg = _parse_mini_yaml(raw)
                normalized = self._normalize_cfg(raw_cfg)
                pending_answers_cfg = _flatten_score_block_static(normalized)
            else:
                pending_answers_cfg = {}

            if current_question is not None and pending_answers_cfg:
                qcfg = current_question.get('config')
                if not isinstance(qcfg, dict):
                    qcfg = {}
                qcfg.update(pending_answers_cfg)
                current_question['config'] = qcfg

            answers_cfg_lines = []
            answers_cfg_collecting = False

        for line in remaining_lines:
            stripped = line.strip()

            qm = q_re.match(stripped)
            if qm:
                if current_question is not None:
                    rc = current_question.get('rich_content', [])
                    while rc and not rc[0].strip():
                        rc.pop(0)
                    while rc and not rc[-1].strip():
                        rc.pop()
                    current_question['rich_content'] = '\n'.join(rc)
                    questions.append(current_question)

                pts = self._parse_points_brace(qm.group(3))
                current_question = {
                    'number': question_number,
                    'text': qm.group(4).strip(),
                    'rich_content': [],
                    'answers': [],
                    'is_meta': False,
                    'points': pts,
                    'config': {},
                    'errors_mode': None,
                    'default_false_in_losing': None,
                }
                question_number += 1
                state = 'question'

                pending_answers_cfg = {}
                pending_answers_started = False
                answers_cfg_collecting = False
                answers_cfg_lines = []
                continue

            am = a_re.match(stripped)
            if am:
                if current_question is not None:
                    state = 'answers'
                    pending_answers_started = True
                    pending_answers_cfg = {}
                    answers_cfg_collecting = False
                    answers_cfg_lines = []
                continue

            if state == 'answers' and pending_answers_started:
                if not current_question:
                    continue

                if not answers_cfg_collecting and stripped == '---' and not pending_answers_cfg:
                    answers_cfg_collecting = True
                    answers_cfg_lines = []
                    continue

                if answers_cfg_collecting:
                    if stripped == '---':
                        flush_answers_cfg()
                        continue
                    answers_cfg_lines.append(line)
                    continue

                if stripped and stripped[0] in ('-', '*', '+'):
                    is_correct_marker = stripped.startswith('+')
                    answer_text = re.sub(r'^[-*+]\s*', '', stripped).strip()

                    ans_points = None
                    m_pts = re.search(r'\{([+-]?\d+(?:\.\d+)?)\}\s*$', answer_text)
                    if m_pts:
                        try:
                            ans_points = float(m_pts.group(1))
                        except Exception:
                            ans_points = None
                        answer_text = re.sub(r'\{[+-]?\d+(?:\.\d+)?\}\s*$', '', answer_text).rstrip()

                    is_correct_brackets = False
                    if answer_text.startswith('[') and answer_text.endswith(']'):
                        is_correct_brackets = True
                        answer_text = answer_text[1:-1].strip()

                    current_question['answers'].append({
                        'text': answer_text,
                        'is_correct': is_correct_marker or is_correct_brackets,
                        'ans_points': ans_points,
                    })
                    continue

                bdict = _parse_inline_braces_dict(stripped)
                if bdict is not None:
                    normalized_bdict = self._normalize_inline_bdict(bdict)
                    current_question['errors_mode'] = normalized_bdict
                    if 'default_false' in normalized_bdict:
                        current_question['default_false_in_losing'] = normalized_bdict.get('default_false')
                    continue

            if state == 'question' and current_question is not None:
                current_question['rich_content'].append(line)

        if current_question is not None:
            rc = current_question.get('rich_content', [])
            while rc and not rc[0].strip():
                rc.pop(0)
            while rc and not rc[-1].strip():
                rc.pop()
            current_question['rich_content'] = '\n'.join(rc)
            questions.append(current_question)

        for q in questions:
            if quiz_type == 'scquiz':
                em: Dict[str, Any] = {}
                any_pts = False
                for ans in q.get('answers', []):
                    ap = ans.get('ans_points')
                    if ap is not None:
                        any_pts = True
                        em[ans['text']] = ap
                if any_pts:
                    for ans in q.get('answers', []):
                        if ans.get('is_correct') and ans['text'] not in em:
                            val = q.get('points')
                            try:
                                em[ans['text']] = float(val) if val is not None else 1.0
                            except Exception:
                                em[ans['text']] = 1.0
                    q['config'] = q.get('config') or {}
                    q['config']['errors_mode'] = em

        questions = self._inject_meta_preamble_question(questions, preamble_lines)

        return {
            'type': quiz_type,
            'title': title,
            'number': number,
            'preamble': '',
            'questions': questions,
            'config': exercise_cfg or {},
        }

    # ─────────────────────────────────────────────────────────────────────────
    # fillblanks
    # ─────────────────────────────────────────────────────────────────────────

    def _parse_fillblanks(self, content: str, quiz_type: str, title: str, number: int) -> dict:
        exercise_cfg, content2 = self._extract_exercise_config(content)
        p_pattern, q_pattern, _ = self._build_keyword_patterns()
        quiz = self._parse_fillblanks_or_scdropdown(
            content2, quiz_type, title, number, p_pattern, q_pattern, mode='fillblanks'
        )
        quiz['config'] = exercise_cfg or {}
        return quiz

    # ─────────────────────────────────────────────────────────────────────────
    # scdropdown
    # ─────────────────────────────────────────────────────────────────────────

    def _parse_scdropdown(self, content: str, quiz_type: str, title: str, number: int) -> dict:
        exercise_cfg, content2 = self._extract_exercise_config(content)
        p_pattern, q_pattern, _ = self._build_keyword_patterns()
        quiz = self._parse_fillblanks_or_scdropdown(
            content2, quiz_type, title, number, p_pattern, q_pattern, mode='scdropdown'
        )
        quiz['config'] = exercise_cfg or {}
        return quiz

    # ─────────────────────────────────────────────────────────────────────────
    # mcdropdown
    # ─────────────────────────────────────────────────────────────────────────

    def _parse_mcdropdown(self, content: str, quiz_type: str, title: str, number: int) -> dict:
        exercise_cfg, content2 = self._extract_exercise_config(content)
        p_pattern, q_pattern, _ = self._build_keyword_patterns()
        quiz = self._parse_fillblanks_or_scdropdown(
            content2, quiz_type, title, number, p_pattern, q_pattern, mode='mcdropdown'
        )
        quiz['config'] = exercise_cfg or {}
        return quiz

    # ─────────────────────────────────────────────────────────────────────────
    # Shared fillblanks / scdropdown / mcdropdown parser
    # ─────────────────────────────────────────────────────────────────────────

    def _extract_question_mini_cfg(self, body_lines: List[str]) -> Tuple[Dict[str, Any], List[str]]:
        lines = body_lines[:]
        start = 0
        while start < len(lines) and not lines[start].strip():
            start += 1
        if start >= len(lines):
            return {}, body_lines

        if lines[start].strip() != '---':
            return {}, body_lines

        end = start + 1
        while end < len(lines) and lines[end].strip() != '---':
            end += 1
        if end >= len(lines):
            return {}, body_lines

        raw_cfg_lines = lines[start + 1:end]
        raw_cfg_lines = _strip_common_indent(raw_cfg_lines)
        raw_cfg = _parse_mini_yaml(raw_cfg_lines)
        cfg = self._normalize_cfg(raw_cfg)

        remaining = lines[:start] + lines[end + 1:]
        return cfg, remaining

    def _parse_fillblanks_or_scdropdown(
        self,
        content: str,
        quiz_type: str,
        title: str,
        number: int,
        p_pattern: str,
        q_pattern: str,
        mode: str
    ) -> dict:
        preamble_lines, remaining_lines = self._split_preamble_and_body(content, p_pattern, q_pattern)

        q_re = re.compile(rf'^({q_pattern})(\d*)\s*(\{{[^}}]*\}})?\s*:\s*(.*)$', re.IGNORECASE)

        blocks = []
        current_block = None

        for line in remaining_lines:
            stripped = line.strip()
            qm = q_re.match(stripped)
            if qm:
                if current_block is not None:
                    blocks.append(current_block)
                pts = self._parse_points_brace(qm.group(3))
                current_block = {
                    'inline_text': qm.group(4).strip(),
                    'body_lines': [],
                    'points': pts,
                }
            else:
                if current_block is not None:
                    current_block['body_lines'].append(line)

        if current_block is not None:
            blocks.append(current_block)

        questions = []
        question_number = 1

        for block in blocks:
            inline_text = block['inline_text']
            body_lines = block['body_lines'][:]
            q_points = block.get('points')

            q_cfg, body_lines2 = self._extract_question_mini_cfg(body_lines)
            body_lines = body_lines2

            while body_lines and not body_lines[0].strip():
                body_lines.pop(0)
            while body_lines and not body_lines[-1].strip():
                body_lines.pop()

            if inline_text:
                text_continuation: List[str] = []
                rich_lines: List[str] = []
                in_rich = False
                for bl in body_lines:
                    bls = bl.strip()
                    if not in_rich and not bls:
                        text_continuation.append(bl)
                        continue
                    # Déclencheurs de rich_content :
                    # - balise HTML block (<div>, <iframe>, etc.)
                    # - image Markdown  ![...](...)  ou  ![](url)
                    # Une ligne de dropdown (commençant par '[' ou '[ [') ou
                    # de texte ordinaire APRES une image retourne dans text_continuation.
                    _is_md_image = bool(re.match(r'^!\[', bls))
                    _is_html_block = bool(_HTML_BLOCK_TAG_RE.match(bls))
                    _is_dropdown_or_text = (
                        not _is_md_image and not _is_html_block
                    )
                    if not in_rich and bls and (_is_html_block or _is_md_image):
                        in_rich = True
                    if in_rich and _is_dropdown_or_text:
                        # Fin du bloc rich (ex: dropdown après image)
                        in_rich = False
                    if in_rich:
                        rich_lines.append(bl)
                    else:
                        text_continuation.append(bl)

                while text_continuation and not text_continuation[-1].strip():
                    text_continuation.pop()

                if text_continuation:
                    continuation = ' '.join(l.strip() for l in text_continuation if l.strip())
                    question_text = (inline_text + ' ' + continuation).strip()
                else:
                    question_text = inline_text

                while rich_lines and not rich_lines[0].strip():
                    rich_lines.pop(0)
                while rich_lines and not rich_lines[-1].strip():
                    rich_lines.pop()
                rich_content = '\n'.join(rich_lines)
            else:
                non_empty = [(i, l) for i, l in enumerate(body_lines) if l.strip()]
                if not non_empty:
                    continue
                # For scdropdown/fillblanks/mcdropdown, ALL body lines may contain markers.
                question_text = ' '.join(l.strip() for l in body_lines if l.strip())
                rich_content = ''

            if mode == 'fillblanks':
                q = self._make_fillblanks_question(question_number, question_text, rich_content)
            elif mode == 'scdropdown':
                q = self._make_scdropdown_question(question_number, question_text, rich_content)
            else:  # mcdropdown
                q = self._make_mcdropdown_question(question_number, question_text, rich_content)

            if q is not None:
                q['points'] = q_points
                q['config'] = q_cfg or {}
                questions.append(q)
                question_number += 1

        questions = self._inject_meta_preamble_question(questions, preamble_lines)

        return {
            'type': quiz_type,
            'title': title,
            'number': number,
            'preamble': '',
            'questions': questions,
        }

    # ─────────────────────────────────────────────────────────────────────────
    # Fillblanks helper
    # ─────────────────────────────────────────────────────────────────────────

    def _make_fillblanks_question(self, number: int, question_text: str, rich_content: str):
        pattern = re.compile(r'(?<!!)(?<!\()\[([^\]]+)\](?!\()(\{[^}]*\})?')
        matches = list(pattern.finditer(question_text))
        if not matches:
            return None

        blanks = []
        for m in matches:
            ans_raw = m.group(1)
            losing_raw = m.group(2)

            answers = [a.strip() for a in ans_raw.split(',') if a.strip()]
            blank_obj: Dict[str, Any] = {'answers': answers}

            if losing_raw:
                d = _parse_inline_braces_dict(losing_raw.strip())
                if d is not None:
                    blank_obj['errors_mode'] = self._normalize_inline_bdict(d)

            blanks.append(blank_obj)

        return {
            'number': number,
            'text': question_text,
            'rich_content': rich_content,
            'blanks': blanks,
            'is_meta': False,
            'config': {},
            'points': None,
        }

    # ─────────────────────────────────────────────────────────────────────────
    # Scdropdown helper
    # ─────────────────────────────────────────────────────────────────────────

    def _make_scdropdown_question(self, number: int, question_text: str, rich_content: str):
        scdropdown_pattern = r'(?<!!)(?<!\()\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\](?!\()(\{[^}]*\})?'
        matches = list(re.finditer(scdropdown_pattern, question_text))
        if not matches:
            return None

        def parse_scdropdown_content(scdropdown_content: str):
            options = []
            correct_index = None
            current_opt = ''
            inside_correct = False

            for part in scdropdown_content.split(','):
                part = part.strip()

                if part.startswith('[') and not part.endswith(']'):
                    inside_correct = True
                    current_opt = part[1:]
                    continue

                if inside_correct and part.endswith(']'):
                    current_opt = (current_opt + ',' + part[:-1]) if current_opt else part[:-1]
                    options.append(current_opt.strip())
                    correct_index = len(options) - 1
                    current_opt = ''
                    inside_correct = False
                    continue

                if part.startswith('[') and part.endswith(']'):
                    options.append(part[1:-1].strip())
                    correct_index = len(options) - 1
                    continue

                if inside_correct:
                    current_opt = (current_opt + ',' + part) if current_opt else part
                    continue

                options.append(part)

            if not options:
                return None, None
            if correct_index is None:
                correct_index = 0
            return options, correct_index

        scdropdowns = []
        out_parts = []
        last_end = 0

        for m in matches:
            options, correct_index = parse_scdropdown_content(m.group(1))
            if options is None:
                continue

            losing_raw = m.group(2)
            losing_dict = None
            if losing_raw:
                d = _parse_inline_braces_dict(losing_raw.strip())
                if d is not None:
                    losing_dict = self._normalize_inline_bdict(d)

            dd_obj: Dict[str, Any] = {'options': options, 'correct_index': correct_index}
            if losing_dict is not None:
                dd_obj['errors_mode'] = losing_dict

            scdropdowns.append(dd_obj)

            out_parts.append(question_text[last_end:m.start()])
            out_parts.append(f'___SCDROPDOWN_{len(scdropdowns) - 1}___')
            last_end = m.end()

        out_parts.append(question_text[last_end:])
        question_text_display = ''.join(out_parts)

        if not scdropdowns:
            return None

        return {
            'number': number,
            'text': question_text_display,
            'rich_content': rich_content,
            'scdropdowns': scdropdowns,
            'scdropdown': scdropdowns[0],
            'is_meta': False,
            'config': {},
            'points': None,
        }

    # ─────────────────────────────────────────────────────────────────────────
    # Mcdropdown helper
    #
    # Syntaxe : [[correct1], wrong, [correct2], wrong2]{inline_errors_mode}
    # Les doubles crochets [[...]] délimitent le groupe d'options du dropdown.
    # À l'intérieur, les options entre [simples crochets] sont correctes.
    # Plusieurs correctes possibles par dropdown.
    # ─────────────────────────────────────────────────────────────────────────

    def _make_mcdropdown_question(self, number: int, question_text: str, rich_content: str):
        # Même pattern que scdropdown : [ [correct], wrong, [correct2] ]{inline_errors}
        # Le groupe est délimité par un seul crochet extérieur.
        # À l'intérieur, les options entre [simples crochets] sont correctes (plusieurs possibles).
        mcdropdown_pattern = r'(?<!!)(?<!\()\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\](?!\()(\{[^}]*\})?'
        matches = list(re.finditer(mcdropdown_pattern, question_text))
        if not matches:
            return None

        def parse_mcdropdown_content(raw: str):
            """
            Parse le contenu intérieur du [...].
            Les options entre [simples crochets] sont correctes (plusieurs possibles).
            Retourne (options: list[str], correct_indices: list[int])
            Gère les virgules à l'intérieur des crochets (ex: options avec virgules).
            """
            options = []
            correct_indices = []
            current_opt = ''
            inside_correct = False

            for part in raw.split(','):
                part = part.strip()
                if not part:
                    continue

                # Début d'une option correcte multi-mots (crochet ouvrant sans fermant)
                if part.startswith('[') and not part.endswith(']'):
                    inside_correct = True
                    current_opt = part[1:]
                    continue

                # Fin d'une option correcte multi-mots
                if inside_correct and part.endswith(']'):
                    current_opt = (current_opt + ',' + part[:-1]) if current_opt else part[:-1]
                    options.append(current_opt.strip())
                    correct_indices.append(len(options) - 1)
                    current_opt = ''
                    inside_correct = False
                    continue

                # Option correcte sur un seul token : [Paris]
                if part.startswith('[') and part.endswith(']'):
                    options.append(part[1:-1].strip())
                    correct_indices.append(len(options) - 1)
                    continue

                # Suite d'une option correcte multi-mots
                if inside_correct:
                    current_opt = (current_opt + ',' + part) if current_opt else part
                    continue

                # Option incorrecte ordinaire
                options.append(part)

            if not options:
                return None, None
            return options, correct_indices

        mcdropdowns = []
        out_parts = []
        last_end = 0

        for m in matches:
            options, correct_indices = parse_mcdropdown_content(m.group(1))
            if options is None:
                continue

            losing_raw = m.group(2)
            losing_dict = None
            if losing_raw:
                d = _parse_inline_braces_dict(losing_raw.strip())
                if d is not None:
                    losing_dict = self._normalize_inline_bdict(d)

            dd_obj: Dict[str, Any] = {
                'options': options,
                'correct_indices': correct_indices if correct_indices else [],
            }
            if losing_dict is not None:
                dd_obj['errors_mode'] = losing_dict

            mcdropdowns.append(dd_obj)

            out_parts.append(question_text[last_end:m.start()])
            out_parts.append(f'___MCDROPDOWN_{len(mcdropdowns) - 1}___')
            last_end = m.end()

        out_parts.append(question_text[last_end:])
        question_text_display = ''.join(out_parts)

        if not mcdropdowns:
            return None

        return {
            'number': number,
            'text': question_text_display,
            'rich_content': rich_content,
            'mcdropdowns': mcdropdowns,
            'is_meta': False,
            'config': {},
            'points': None,
        }

    # ─────────────────────────────────────────────────────────────────────────
    # orderquiz
    # ─────────────────────────────────────────────────────────────────────────

    def _parse_orderquiz(self, content: str, quiz_type: str, title: str, number: int) -> dict:
        exercise_cfg, content2 = self._extract_exercise_config(content)
        p_pattern, q_pattern, _ = self._build_keyword_patterns()
        preamble_lines, remaining_lines = self._split_preamble_and_body(content2, p_pattern, q_pattern)

        q_re = re.compile(rf'^({q_pattern})(\d*)\s*(\{{[^}}]*\}})?\s*:\s*(.*)$', re.IGNORECASE)
        # Bullet item : *, -, +, ou liste ordonnée  0. 1. 2. etc.
        item_re = re.compile(r'^(?:[-*+]|\d+\.)\s+(.+)$')

        questions = []
        question_number = 1
        current_block = None

        for line in remaining_lines:
            stripped = line.strip()
            qm = q_re.match(stripped)
            if qm:
                if current_block is not None:
                    questions.append(self._finalize_orderquiz_question(current_block, question_number))
                    question_number += 1
                pts = self._parse_points_brace(qm.group(3))
                current_block = {
                    'body_lines': [],
                    'points': pts,
                    'config_raw': {},
                }
                continue

            if current_block is not None:
                current_block['body_lines'].append(line)

        if current_block is not None:
            questions.append(self._finalize_orderquiz_question(current_block, question_number))

        questions = self._inject_meta_preamble_question(questions, preamble_lines)

        return {
            'type': quiz_type,
            'title': title,
            'number': number,
            'preamble': '',
            'questions': questions,
            'config': exercise_cfg or {},
        }

    def _finalize_orderquiz_question(self, block: dict, number: int) -> dict:
        body_lines = block['body_lines'][:]

        # Extraire éventuel front-matter --- ... ---
        q_cfg, body_lines = self._extract_question_mini_cfg(body_lines)

        # Supprimer lignes vides en tête/queue
        while body_lines and not body_lines[0].strip():
            body_lines.pop(0)
        while body_lines and not body_lines[-1].strip():
            body_lines.pop()

        item_re = re.compile(r'^(?:[-*+]|\d+\.)\s+(.+)$')
        items = []
        for ln in body_lines:
            m = item_re.match(ln.strip())
            if m:
                items.append(m.group(1).strip())

        return {
            'number': number,
            'text': '',
            'rich_content': '',
            'items': items,           # ordre CORRECT défini par l'auteur
            'is_meta': False,
            'points': block.get('points'),
            'config': q_cfg or {},
        }
