# """
# Plugin principal MkDocs pour mkdocs-superquiz

# Features in this version:
# 1) ✅ Fix repeated quiz titles:
#    - If the same admonition title appears multiple times on a page,
#      map title -> queue (deque) and consume quizzes in order.

# 2) ✅ Preamble markers are NOT "Q0":
#    - Synthetic meta question 0 (is_meta=True / number==0) is rendered/extracted
#      using dedicated PREAMBLE markers:
#        <!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->
#        ...
#        <!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->

# 3) ✅ Config override levels:
#    - Global (mkdocs.yml plugin config)   [lowest priority]
#    - Page (front-matter `superquiz:` or `quiz:` block)   [overrides global]
#    - Exercise (mini front-matter inside the quiz) [overrides page + global]

# 4) ✅ support "defaults" / "global" block in mkdocs.yml with rule:
#    - NESTED > FLAT (at the same level)
#    - i.e. defaults/global overrides root keys, including language.

# 5) ✅ on_post_page replaces on_page_context for reliable asset injection
#    - Works with MkDocs Material and any theme
#    - Depth-aware relative paths for nested pages

# 6) ✅ min_score semantics:
#    - Default = 0 (no negative scores)
#    - min_score: none / "none" / "null" → no minimum (negative scores allowed)
#    - min_score: N → minimum N, but ignored if N >= points_max (aberrant case)
# """

# import os
# import re
# import shutil
# import json
# import posixpath
# from pathlib import Path
# from collections import defaultdict, deque
# from typing import Any, Dict

# from mkdocs.config import config_options
# from mkdocs.plugins import BasePlugin
# from mkdocs.structure.files import Files
# from mkdocs.structure.pages import Page
# from mkdocs.config.defaults import MkDocsConfig

# from .parser import QuizParser
# from .renderer import QuizRenderer, _flatten_score_block
# from .database import QuizDatabase
# from .security import prepare_teacher_code_hash
# from .config import DEFAULT_CONFIG, DEFAULTS_ALIASES
# from .locale import QuizI18n


# # ─────────────────────────────────────────────────────────────────────────────
# # Admonition body-line detector  (must stay in sync with parser.py)
# # ─────────────────────────────────────────────────────────────────────────────

# _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))
#     )


# # ─────────────────────────────────────────────────────────────────────────────
# # Deep merge helpers
# # ─────────────────────────────────────────────────────────────────────────────

# def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
#     """
#     Recursively merge `override` into `base` and return a new dict.
#     - dict values are merged recursively
#     - other values replace the base value
#     """
#     if not isinstance(base, dict):
#         base = {}
#     if not isinstance(override, dict):
#         override = {}

#     out: Dict[str, Any] = dict(base)
#     for k, v in override.items():
#         if isinstance(v, dict) and isinstance(out.get(k), dict):
#             out[k] = _deep_merge(out[k], v)
#         else:
#             out[k] = v
#     return out


# def _pick_quiz_type_config(cfg: Dict[str, Any], quiz_type: str) -> Dict[str, Any]:
#     if not isinstance(cfg, dict):
#         cfg = {}

#     QUIZ_CFG_KEYS = {
#         'show_correction', 'scoring_mode', 'default_false', 'default',
#         'save_answers', 'randomize_answers', 'randomize_questions',
#         'min_score', 'clamp_max_score', 'errors_mode', 'score', 'match',
#     }

#     result: Dict[str, Any] = dict(cfg)

#     qt_cfg = cfg.get(quiz_type, {})
#     if not isinstance(qt_cfg, dict):
#         qt_cfg = {}
#     qt_cfg = _flatten_score_block(qt_cfg)

#     # Expandre randomize: dans qt_cfg si présent
#     if 'randomize' in qt_cfg:
#         from .locale import QuizI18n
#         _i18n_tmp = QuizI18n('en')
#         qt_cfg = dict(qt_cfg)
#         qt_cfg.update(_i18n_tmp._expand_randomize(qt_cfg.pop('randomize')))

#     cfg_flat = _flatten_score_block(cfg)
#     # Expandre randomize: dans cfg_flat si présent
#     if 'randomize' in cfg_flat:
#         from .locale import QuizI18n
#         _i18n_tmp = QuizI18n('en')
#         cfg_flat = dict(cfg_flat)
#         cfg_flat.update(_i18n_tmp._expand_randomize(cfg_flat.pop('randomize')))

#     for k in QUIZ_CFG_KEYS:
#         if k == 'score':
#             continue
#         if k in cfg_flat and cfg_flat[k] is not None and k not in qt_cfg:
#             qt_cfg = dict(qt_cfg)
#             qt_cfg[k] = cfg_flat[k]

#     for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown'):
#         if qt not in result or not isinstance(result.get(qt), dict):
#             result[qt] = {}

#     result[quiz_type] = qt_cfg
#     return result



# def _normalize_exercise_config(ex_cfg: Dict[str, Any], quiz_type: str) -> Dict[str, Any]:
#     """
#     Accept both styles for exercise config:

#     Flat:
#         show_correction: on_demand
#         scoring_mode: proportional
#         default_false: 0
#         compare_answers: { ... }   (fillblanks)

#     Nested:
#         mcquiz:
#           show_correction: on_demand
#     """
#     if not isinstance(ex_cfg, dict):
#         return {}

#     if isinstance(ex_cfg.get(quiz_type), dict):
#         return ex_cfg

#     flat = dict(ex_cfg)
#     out: Dict[str, Any] = {}

#     global_keys = {
#         'security_mode',
#         'encryption_key',
#         'api_endpoint',
#         'correction_granularity',
#         'correction_locked_icon',
#         'correction_unlock_icon',
#         'correction_unlocked_icon',
#         'language',
#         'min_score',
#         'clamp_max_score',
#     }
#     for k in list(flat.keys()):
#         if k in global_keys:
#             out[k] = flat.pop(k)

#     out[quiz_type] = flat
#     return out


# # ─────────────────────────────────────────────────────────────────────────────
# # min_score normalization
# # ─────────────────────────────────────────────────────────────────────────────

# def _normalize_min_score(value: Any) -> Any:
#     if value is None:
#         return None
#     if isinstance(value, str) and value.lower() in ('none', 'null'):
#         return None
#     return value

# # ─────────────────────────────────────────────────────────────────────────────
# # bool_strings normalization
# # ─────────────────────────────────────────────────────────────────────────────

# def _normalize_bool_strings(cfg: dict, keys: tuple) -> dict:
#     """
#     YAML parses 'yes'/'no'/'true'/'false' as Python booleans.
#     For string-semantic config keys (show_correction, scoring_mode, etc.),
#     convert booleans back to their canonical string equivalents.
#     """
#     _BOOL_TO_STR = {True: 'yes', False: 'no'}
#     if not isinstance(cfg, dict):
#         return cfg
#     result = dict(cfg)
#     for key in keys:
#         if key in result and isinstance(result[key], bool):
#             result[key] = _BOOL_TO_STR[result[key]]
#     return result

# _STRING_BOOL_KEYS = (
#     'show_correction', 'scoring_mode', 'default_false', 'save_answers',
#     'randomize_questions', 'randomize_answers', 'errors_mode',
# )


# # ─────────────────────────────────────────────────────────────────────────────
# # Plugin
# # ─────────────────────────────────────────────────────────────────────────────

# class QuizPlugin(BasePlugin):
#     # ============= DO NOT REMOVE : BEGINNING OF MKDOCS-REVEALJS COMPATIBILITY SNIPPET : DO NOT REMOVE =====================
#     # Declares the HTML marker patterns injected into admonition bodies during
#     # on_page_markdown. mkdocs-revealjs uses these to identify admonitions it
#     # must not re-render itself, and to delegate on_page_content processing
#     # back to this plugin on the rendered slide HTML fragment.
#     # Requires: superquiz declared BEFORE revealjs in mkdocs.yml plugins:
#     REVEALJS_MANAGED_ADMONITIONS = [
#         re.compile(r'<!--\s*MQS_\d+_', re.IGNORECASE),
#     ]
#     # ============= DO NOT REMOVE: END OF MKDOCS-COMPATIBILITY SNIPPET : DO NOT REMOVE =========================

#     config_scheme = (
#         ('language', config_options.Type(str, default='en')),

#         ('defaults', config_options.Type(dict, default={})),
#         ('global',   config_options.Type(dict, default={})),

#         ('security_mode', config_options.Type(str, default='obfuscated')),
#         ('teacher_code', config_options.Type(str, default=None)),
#         ('encryption_key', config_options.Type(str, default='default-key-change-me')),
#         ('api_endpoint', config_options.Type(str, default=None)),
#         ('correction_granularity', config_options.Type(str, default='all')),

#         ('min_score', config_options.Type((int, float, str), default=0)),
#         ('clamp_max_score', config_options.Type(bool, default=True)),

#         ('mcquiz',      config_options.Type(dict, default={})),
#         ('scquiz',      config_options.Type(dict, default={})),
#         ('fillblanks',  config_options.Type(dict, default={})),
#         ('scdropdown',  config_options.Type(dict, default={})),
#         ('mcdropdown',  config_options.Type(dict, default={})),
#         ('orderquiz',   config_options.Type(dict, default={})),
#         ('correction_locked_icon',   config_options.Type(str, default='❌')),
#         ('correction_unlock_icon',   config_options.Type(str, default='🔐')),
#         ('correction_unlocked_icon', config_options.Type(str, default='✅')),
#     )

#     def __init__(self):
#         super().__init__()
#         self.db = None
#         self.teacher_code_hash = None
#         self.i18n = None
#         self.language = 'en'

#         plugin_dir = Path(__file__).parent
#         self.assets_dir = plugin_dir / 'assets'
#         self.templates_dir = plugin_dir / 'templates'

#     # ─────────────────────────────────────────────────────────────────────────
#     # defaults/global handling (nested > flat)
#     # ─────────────────────────────────────────────────────────────────────────

#     def _extract_defaults_block(self, cfg: Dict[str, Any]) -> Dict[str, Any]:
#         out: Dict[str, Any] = {}
#         for k in DEFAULTS_ALIASES:
#             block = cfg.get(k)
#             if isinstance(block, dict) and block:
#                 out = _deep_merge(out, block)
#         return out

#     def _apply_nested_over_flat(self, raw_cfg: Dict[str, Any]) -> Dict[str, Any]:
#         flat = dict(raw_cfg)
#         for k in DEFAULTS_ALIASES:
#             if k in flat:
#                 flat.pop(k, None)
#         defaults_block = self._extract_defaults_block(raw_cfg)
#         merged = _deep_merge(flat, defaults_block)
#         return merged

#     # ─────────────────────────────────────────────────────────────────────────
#     # on_config
#     # ─────────────────────────────────────────────────────────────────────────

#     def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
#         merged_cfg = self._apply_nested_over_flat(dict(self.config))
#         self.config = merged_cfg

#         self.config['min_score'] = _normalize_min_score(self.config.get('min_score'))

#         self.language = self.config.get('language', 'en')

#         from .renderer import _flatten_score_block as _fsb
#         _i18n_tmp = QuizI18n(self.language)

#         # Aplatir score: top-level + expandre randomize: top-level
#         self.config = _fsb(self.config)
#         if 'randomize' in self.config:
#             self.config.update(_i18n_tmp._expand_randomize(self.config.pop('randomize')))

#         PROPAGATABLE_KEYS = {
#             'scoring_mode', 'default_false', 'default', 'show_correction',
#             'save_answers', 'randomize', 'min_score', 'clamp_max_score',
#         }
#         top_level_flat = _fsb(self.config)

#         for quiz_type in ['mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz']:
#             user_type_config = self.config.get(quiz_type, {})
#             if not isinstance(user_type_config, dict):
#                 user_type_config = {}
#             user_type_config = _fsb(user_type_config)
#             # Expandre randomize: dans le sous-dict type venant de mkdocs.yml
#             if 'randomize' in user_type_config:
#                 user_type_config.update(_i18n_tmp._expand_randomize(user_type_config.pop('randomize')))

#             # 1. Défauts depuis DEFAULT_CONFIG
#             merged_type = dict(DEFAULT_CONFIG.get(quiz_type, {}))
#             # Expandre randomize: dans DEFAULT_CONFIG (ex: randomize: 'none')
#             if 'randomize' in merged_type:
#                 merged_type.update(_i18n_tmp._expand_randomize(merged_type.pop('randomize')))

#             # 2. Top-level mkdocs.yml — plus spécifique que défauts
#             # On ne propage PAS randomize directement : il a déjà été expandu
#             # en randomize_answers/randomize_questions dans self.config top-level.
#             # On propage uniquement les clés déjà expandues.
#             for k in ('randomize_answers', 'randomize_questions'):
#                 if k in top_level_flat and top_level_flat[k] is not None:
#                     merged_type[k] = top_level_flat[k]
#             for k in PROPAGATABLE_KEYS - {'randomize'}:
#                 if k in top_level_flat and top_level_flat[k] is not None:
#                     if k not in ('randomize_answers', 'randomize_questions'):
#                         merged_type[k] = top_level_flat[k]

#             # 3. Sous-dict type — le plus spécifique
#             merged_type.update(user_type_config)
#             self.config[quiz_type] = merged_type

#         custom_synonyms = {qt: self.config.get(qt, {}) for qt in ['mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz']}
#         self.i18n = QuizI18n(self.language, custom_synonyms)

#         print(f"🌍 Language: {self.language}")
#         print(f"   Recognized quiz type synonyms: {', '.join(self.i18n.get_all_quiz_type_synonyms())}")

#         self.teacher_code_hash = prepare_teacher_code_hash(
#             self.config.get('teacher_code') or 'CHANGE_ME'
#         )

#         db_path = os.path.join(config['docs_dir'], 'quizzes.db')
#         self.db = QuizDatabase(db_path)

#         return config


#     # ─────────────────────────────────────────────────────────────────────────
#     # Page-level overrides
#     # ─────────────────────────────────────────────────────────────────────────

#     def _get_page_overrides(self, page: Page) -> Dict[str, Any]:
#         if not hasattr(page, 'meta') or not page.meta:
#             return {}
#         q = page.meta.get('superquiz') or page.meta.get('quiz')
#         if not isinstance(q, dict):
#             return {}
#         # Normalize top-level bool strings
#         q = _normalize_bool_strings(q, _STRING_BOOL_KEYS)
#         # Normalize inside each quiz-type sub-dict
#         for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown'):
#             if isinstance(q.get(qt), dict):
#                 q[qt] = _normalize_bool_strings(q[qt], _STRING_BOOL_KEYS)
#         return q

#     def _effective_config_for_quiz(self, page: Page, quiz: Dict[str, Any]) -> Dict[str, Any]:
#         global_cfg = dict(self.config)
#         page_cfg_raw = self._get_page_overrides(page)

#         page_cfg = self.i18n.normalize_config_dict(page_cfg_raw) if page_cfg_raw else {}

#         # Aplatir les blocs score: dans chaque sous-dict de type de quiz du page_cfg
#         # avant le merge, pour que score:{mode:X} puisse écraser scoring_mode du global.
#         # Ex: page_cfg['mcquiz'] = {score:{mode:all_or_nothing}} →
#         #     page_cfg['mcquiz'] = {scoring_mode:all_or_nothing}
#         from .renderer import _flatten_score_block as _fsb
#         page_cfg_flat = dict(page_cfg)
#         for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown'):
#             if isinstance(page_cfg_flat.get(qt), dict):
#                 page_cfg_flat[qt] = _fsb(page_cfg_flat[qt])
#         # Aplatir aussi au top-level (si score: est directement dans page_cfg)
#         page_cfg_flat = _fsb(page_cfg_flat)

#         merged = _deep_merge(global_cfg, page_cfg_flat)

#         quiz_type = quiz.get('type', '')
#         ex_cfg_raw = quiz.get('config') or {}
#         # Expandre randomize: dans le front-matter quiz avant normalisation
#         if isinstance(ex_cfg_raw, dict) and 'randomize' in ex_cfg_raw:
#             expanded = self.i18n._expand_randomize(ex_cfg_raw.pop('randomize'))
#             ex_cfg_raw.update(expanded)
#         ex_cfg = _normalize_exercise_config(ex_cfg_raw, quiz_type)
#         merged = _deep_merge(merged, ex_cfg)
#         merged = _pick_quiz_type_config(merged, quiz_type)

#         merged['min_score'] = _normalize_min_score(merged.get('min_score'))

#         return merged

#     # ─────────────────────────────────────────────────────────────────────────
#     # on_page_markdown
#     # ─────────────────────────────────────────────────────────────────────────

#     def on_page_markdown(self, markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str:
#         parser = QuizParser(self.i18n, self.language)
#         quizzes = parser.parse_markdown(markdown)

#         if not quizzes:
#             return markdown

#         page_path = page.file.src_path
#         page_title = page.title or page_path

#         corrections_locked = False
#         if hasattr(page, 'meta') and page.meta:
#             show_correction = page.meta.get('quiz', {}).get('show_correction', 'on_demand')
#             corrections_locked = (show_correction == 'on_demand')

#         page_uuid = self.db.get_or_create_page(page_path, page_title, corrections_locked)
#         self.db._clean_page_quizzes(page_uuid)

#         for quiz in quizzes:
#             quiz_db_id = self.db.add_quiz(page_uuid, quiz['number'], quiz['type'], quiz['title'])

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

#                 qid = self.db.add_question(quiz_db_id, question['number'], question.get('text', ''))

#                 if quiz['type'] in ['mcquiz', 'scquiz']:
#                     for idx, answer in enumerate(question.get('answers', [])):
#                         self.db.add_answer(qid, answer['text'], answer.get('is_correct', False), idx)

#                 elif quiz['type'] == 'fillblanks':
#                     for blank_idx, blank in enumerate(question.get('blanks', [])):
#                         for answer in blank.get('answers', []):
#                             self.db.add_answer(qid, answer, True, blank_idx)

#                 elif quiz['type'] == 'scdropdown':
#                     scdropdowns = question.get('scdropdowns') or []
#                     if not scdropdowns:
#                         dd = question.get('scdropdown')
#                         scdropdowns = [dd] if dd else []
#                     if scdropdowns:
#                         dd = scdropdowns[0]
#                         ci = dd.get('correct_index', 0)
#                         for idx, option in enumerate(dd.get('options', [])):
#                             self.db.add_answer(qid, option, idx == ci, idx)

#                 elif quiz['type'] == 'mcdropdown':
#                     mcdropdowns = question.get('mcdropdowns') or []
#                     for dd_idx, dd in enumerate(mcdropdowns):
#                         if not dd:
#                             continue
#                         correct_indices = set(dd.get('correct_indices', []))
#                         for idx, option in enumerate(dd.get('options', [])):
#                             self.db.add_answer(qid, option, idx in correct_indices, idx)
#                 elif quiz['type'] == 'orderquiz':
#                     for q_idx, item in enumerate(question.get('items', [])):
#                         self.db.add_answer(qid, item, True, q_idx)

#         modified_markdown = self._rewrite_admonitions(markdown, quizzes)

#         if not hasattr(page, '_mkdocs_quiz_data'):
#             page._mkdocs_quiz_data = {}
#         page._mkdocs_quiz_data['quizzes'] = quizzes
#         page._mkdocs_quiz_data['page_uuid'] = page_uuid
#         page._mkdocs_quiz_data['page_path'] = page_path

#         return modified_markdown

#     # ─────────────────────────────────────────────────────────────────────────
#     # Admonition rewriter
#     # ─────────────────────────────────────────────────────────────────────────

#     def _rewrite_admonitions(self, markdown: str, quizzes: list) -> str:
#         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)

#         quizzes_by_title = defaultdict(deque)
#         for q in quizzes:
#             quizzes_by_title[q['title']].append(q)

#         src_lines = markdown.split('\n')
#         out_lines = []
#         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:
#                 title = hm.group(2)

#                 i += 1
#                 while i < len(src_lines) and _is_admonition_body_line(src_lines[i]):
#                     i += 1

#                 out_lines.append(line)

#                 quiz = quizzes_by_title[title].popleft() if quizzes_by_title[title] else None
#                 if quiz:
#                     out_lines.append(self._build_admonition_body(quiz))
#             else:
#                 out_lines.append(line)
#                 i += 1

#         return '\n'.join(out_lines)

#     # def _build_admonition_body(self, quiz: dict) -> str:
#     #     quiz_num = quiz['number']
#     #     quiz_type = quiz.get('type', '')
#     #     indent = '    '
#     #     blocks = []

#     #     def strip_source_indent(raw: str) -> str:
#     #         lines = raw.split('\n')
#     #         indents = [
#     #             len(ln) - len(ln.lstrip())
#     #             for ln in lines
#     #             if ln.strip() and ln[0] in (' ', '\t')
#     #         ]
#     #         min_indent = min(indents) if indents else 0
#     #         result = []
#     #         for ln in lines:
#     #             if not ln.strip():
#     #                 result.append('')
#     #             elif ln[0] in (' ', '\t'):
#     #                 result.append(ln[min_indent:])
#     #             else:
#     #                 result.append(ln)
#     #         return '\n'.join(result)

#     #     def ensure_blank(text: str) -> str:
#     #         block_tags = (r'(?:div|iframe|figure|figcaption|table|thead|tbody|'
#     #                       r'tr|th|td|blockquote|pre|p|h[1-6]|video|audio|details|summary)')
#     #         self_closing = r'(?:img|br|hr|input|source)'

#     #         text = re.sub(
#     #             rf'(</{block_tags}\s*>)([ \t]*)$',
#     #             r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
#     #         )
#     #         text = re.sub(
#     #             rf'(<{self_closing}[^>]*/?>)([ \t]*)$',
#     #             r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
#     #         )

#     #         lines = text.split('\n')
#     #         result = []
#     #         for line in lines:
#     #             stripped = line.strip()
#     #             is_list = (
#     #                 (stripped and stripped[0] in ('*', '-', '+')
#     #                  and len(stripped) > 1 and stripped[1] == ' ') or
#     #                 bool(stripped and re.match(r'^\d+\.\s', stripped))
#     #             )
#     #             if is_list and result:
#     #                 prev = result[-1].strip()
#     #                 prev_is_list = (
#     #                     (prev and prev[0] in ('*', '-', '+')
#     #                      and len(prev) > 1 and prev[1] == ' ') or
#     #                     bool(prev and re.match(r'^\d+\.\s', prev))
#     #                 )
#     #                 if prev and not prev_is_list:
#     #                     result.append('')
#     #             result.append(line)
#     #         return '\n'.join(result)

#     #     def build_rich_block(raw: str, start_marker: str, end_marker: str) -> str:
#     #         cleaned = strip_source_indent(raw)
#     #         cleaned = ensure_blank(cleaned)
#     #         content_lines = [
#     #             '' if ln.strip() == '' else f'{indent}{ln}'
#     #             for ln in cleaned.split('\n')
#     #         ]
#     #         parts = ['', f'{indent}{start_marker}', ''] + content_lines + ['', f'{indent}{end_marker}', '']
#     #         return '\n'.join(parts)

#     #     for q in quiz.get('questions', []):
#     #         q_num = q.get('number', 0)
#     #         is_meta = bool(q.get('is_meta')) or (q_num == 0)

#     #         if (quiz_type in ('mcquiz', 'scquiz')) and (not is_meta):
#     #             q_text = (q.get('text') or '').strip()
#     #             if q_text:
#     #                 blocks.append('\n'.join([
#     #                     '',
#     #                     f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
#     #                     '',
#     #                     f'{indent}{q_text}',
#     #                     '',
#     #                     f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->',
#     #                     '',
#     #                 ]))

#     #         rich = q.get('rich_content', '') or ''
#     #         rlines = rich.split('\n')
#     #         while rlines and not rlines[0].strip():
#     #             rlines.pop(0)
#     #         while rlines and not rlines[-1].strip():
#     #             rlines.pop()

#     #         if not rlines:
#     #             continue

#     #         if is_meta:
#     #             blocks.append(build_rich_block(
#     #                 '\n'.join(rlines),
#     #                 f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
#     #                 f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
#     #             ))
#     #         else:
#     #             blocks.append(build_rich_block(
#     #                 '\n'.join(rlines),
#     #                 f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
#     #                 f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
#     #             ))

#     #     if not blocks:
#     #         return f'{indent}<!-- QUIZ_EMPTY_{quiz_num} -->\n'

#     #     return '\n'.join(blocks) + '\n'

#     def _build_admonition_body(self, quiz: dict) -> str:
#         quiz_num = quiz['number']
#         quiz_type = quiz.get('type', '')
#         indent = '    '
#         blocks = []

#         def strip_source_indent(raw: str) -> str:
#             lines = raw.split('\n')
#             indents = [
#                 len(ln) - len(ln.lstrip())
#                 for ln in lines
#                 if ln.strip() and ln[0] in (' ', '\t')
#             ]
#             min_indent = min(indents) if indents else 0
#             result = []
#             for ln in lines:
#                 if not ln.strip():
#                     result.append('')
#                 elif ln[0] in (' ', '\t'):
#                     result.append(ln[min_indent:])
#                 else:
#                     result.append(ln)
#             return '\n'.join(result)

#         def ensure_blank(text: str) -> str:
#             block_tags = (r'(?:div|iframe|figure|figcaption|table|thead|tbody|'
#                         r'tr|th|td|blockquote|pre|p|h[1-6]|video|audio|details|summary)')
#             self_closing = r'(?:img|br|hr|input|source)'

#             text = re.sub(
#                 rf'(</{block_tags}\s*>)([ \t]*)$',
#                 r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
#             )
#             text = re.sub(
#                 rf'(<{self_closing}[^>]*/?>)([ \t]*)$',
#                 r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
#             )

#             lines = text.split('\n')
#             result = []
#             for line in lines:
#                 stripped = line.strip()
#                 is_list = (
#                     (stripped and stripped[0] in ('*', '-', '+')
#                     and len(stripped) > 1 and stripped[1] == ' ') or
#                     bool(stripped and re.match(r'^\d+\.\s', stripped))
#                 )
#                 if is_list and result:
#                     prev = result[-1].strip()
#                     prev_is_list = (
#                         (prev and prev[0] in ('*', '-', '+')
#                         and len(prev) > 1 and prev[1] == ' ') or
#                         bool(prev and re.match(r'^\d+\.\s', prev))
#                     )
#                     if prev and not prev_is_list:
#                         result.append('')
#                 result.append(line)
#             return '\n'.join(result)

#         def build_rich_block(raw: str, start_marker: str, end_marker: str) -> str:
#             cleaned = strip_source_indent(raw)
#             cleaned = ensure_blank(cleaned)
#             content_lines = [
#                 '' if ln.strip() == '' else f'{indent}{ln}'
#                 for ln in cleaned.split('\n')
#             ]
#             parts = ['', f'{indent}{start_marker}', ''] + content_lines + ['', f'{indent}{end_marker}', '']
#             return '\n'.join(parts)

#         for q in quiz.get('questions', []):
#             q_num = q.get('number', 0)
#             is_meta = bool(q.get('is_meta')) or (q_num == 0)

#             # ── mcquiz / scquiz : émettre le texte de la question ──────────────
#             if (quiz_type in ('mcquiz', 'scquiz')) and (not is_meta):
#                 q_text = (q.get('text') or '').strip()
#                 if q_text:
#                     blocks.append('\n'.join([
#                         '',
#                         f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
#                         '',
#                         f'{indent}{q_text}',
#                         '',
#                         f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->',
#                         '',
#                     ]))

#             # ── orderquiz : pas de text ni rich_content utiles —
#             # On émet un marqueur vide pour que _replace_admonition() trouve
#             # l'admonition à remplacer dans le HTML rendu par MkDocs.
#             # Les items sont dans question['items'] et traités côté renderer.
#             if (quiz_type == 'orderquiz') and (not is_meta):
#                 blocks.append(f'\n{indent}<!-- MQS_{quiz_num}_ORDERQUIZ_Q{q_num} -->\n')
#                 continue  # pas de rich_content à émettre pour orderquiz

#             # ── rich_content (images, iframes, etc.) ───────────────────────────
#             rich = q.get('rich_content', '') or ''
#             rlines = rich.split('\n')
#             while rlines and not rlines[0].strip():
#                 rlines.pop(0)
#             while rlines and not rlines[-1].strip():
#                 rlines.pop()

#             if not rlines:
#                 continue

#             if is_meta:
#                 blocks.append(build_rich_block(
#                     '\n'.join(rlines),
#                     f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
#                     f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
#                 ))
#             else:
#                 blocks.append(build_rich_block(
#                     '\n'.join(rlines),
#                     f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
#                     f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
#                 ))

#         if not blocks:
#             return f'{indent}<!-- QUIZ_EMPTY_{quiz_num} -->\n'

#         return '\n'.join(blocks) + '\n'



#     # ─────────────────────────────────────────────────────────────────────────
#     # on_page_content
#     # ─────────────────────────────────────────────────────────────────────────

#     # def on_page_content(self, html: str, page: Page, config: MkDocsConfig, files: Files) -> str:
#     #     if not hasattr(page, '_mkdocs_quiz_data'):
#     #         return html

#     #     quiz_data = page._mkdocs_quiz_data
#     #     quizzes = quiz_data.get('quizzes', [])
#     #     page_uuid = quiz_data.get('page_uuid')
#     #     page_path = quiz_data.get('page_path', '')

#     #     if not quizzes:
#     #         return html

#     #     modified_html = html

#     #     for quiz in quizzes:
#     #         quiz_num = quiz['number']

#     #         for q in quiz.get('questions', []):
#     #             q_num = q.get('number', 0)
#     #             is_meta = bool(q.get('is_meta')) or (q_num == 0)

#     #             if (quiz.get('type') in ('mcquiz', 'scquiz')) and (not is_meta):
#     #                 text_html, modified_html = self._extract_marked(
#     #                     modified_html,
#     #                     f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
#     #                     f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->'
#     #                 )
#     #                 if text_html is not None:
#     #                     p_count = len(re.findall(r'<p>', text_html, re.IGNORECASE))
#     #                     if p_count == 1:
#     #                         text_html = re.sub(
#     #                             r'^\s*<p>(.*?)</p>\s*$',
#     #                             r'\1',
#     #                             text_html,
#     #                             flags=re.DOTALL | re.IGNORECASE
#     #                         )
#     #                     q['text_html'] = self._fix_image_paths(text_html, page_path)

#     #             if is_meta:
#     #                 rich_html, modified_html = self._extract_marked(
#     #                     modified_html,
#     #                     f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
#     #                     f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
#     #                 )
#     #             else:
#     #                 rich_html, modified_html = self._extract_marked(
#     #                     modified_html,
#     #                     f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
#     #                     f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
#     #                 )

#     #             if rich_html is not None:
#     #                 q['rich_content'] = self._fix_image_paths(rich_html, page_path)

#     #         effective_cfg = self._effective_config_for_quiz(page, quiz)
#     #         renderer = QuizRenderer(effective_cfg, page_uuid, self.teacher_code_hash, self.i18n, self.language)

#     #         html_class = quiz.get('original_synonym', quiz['type']) or quiz['type']
#     #         quiz_title = re.escape(quiz['title'])
#     #         quiz_html = renderer.render_quiz(quiz)
#     #         modified_html = self._replace_admonition(modified_html, html_class, quiz_title, quiz_html)

#     #     return modified_html

#     def on_page_content(self, html: str, page: Page, config: MkDocsConfig, files: Files) -> str:
#         if not hasattr(page, '_mkdocs_quiz_data'):
#             return html

#         quiz_data = page._mkdocs_quiz_data
#         quizzes = quiz_data.get('quizzes', [])
#         page_uuid = quiz_data.get('page_uuid')
#         page_path = quiz_data.get('page_path', '')

#         if not quizzes:
#             return html

#         modified_html = html

#         for quiz in quizzes:
#             quiz_num = quiz['number']
#             quiz_type = quiz.get('type', '')

#             for q in quiz.get('questions', []):
#                 q_num = q.get('number', 0)
#                 is_meta = bool(q.get('is_meta')) or (q_num == 0)

#                 # orderquiz : pas de text_html ni rich_content à extraire —
#                 # les items sont déjà dans question['items'], traités par le renderer.
#                 if quiz_type == 'orderquiz' and not is_meta:
#                     continue

#                 if (quiz_type in ('mcquiz', 'scquiz')) and (not is_meta):
#                     text_html, modified_html = self._extract_marked(
#                         modified_html,
#                         f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
#                         f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->'
#                     )
#                     if text_html is not None:
#                         p_count = len(re.findall(r'<p>', text_html, re.IGNORECASE))
#                         if p_count == 1:
#                             text_html = re.sub(
#                                 r'^\s*<p>(.*?)</p>\s*$',
#                                 r'\1',
#                                 text_html,
#                                 flags=re.DOTALL | re.IGNORECASE
#                             )
#                         q['text_html'] = self._fix_image_paths(text_html, page_path)

#                 if is_meta:
#                     rich_html, modified_html = self._extract_marked(
#                         modified_html,
#                         f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
#                         f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
#                     )
#                 else:
#                     rich_html, modified_html = self._extract_marked(
#                         modified_html,
#                         f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
#                         f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
#                     )

#                 if rich_html is not None:
#                     q['rich_content'] = self._fix_image_paths(rich_html, page_path)

#             effective_cfg = self._effective_config_for_quiz(page, quiz)
#             renderer = QuizRenderer(effective_cfg, page_uuid, self.teacher_code_hash, self.i18n, self.language)

#             html_class = quiz.get('original_synonym', quiz['type']) or quiz['type']
#             quiz_title = re.escape(quiz['title'])
#             quiz_html = renderer.render_quiz(quiz)
#             modified_html = self._replace_admonition(modified_html, html_class, quiz_title, quiz_html)

#         return modified_html


#     # ─────────────────────────────────────────────────────────────────────────
#     # Image path fix
#     # ─────────────────────────────────────────────────────────────────────────

#     def _fix_image_paths(self, html_fragment: str, page_src_path: str) -> str:
#         if not page_src_path:
#             return html_fragment

#         page_dir = posixpath.dirname(page_src_path)
#         page_stem = posixpath.splitext(posixpath.basename(page_src_path))[0]
#         page_out_dir = posixpath.join(page_dir, page_stem) if page_dir else page_stem

#         def rewrite(m):
#             attr, quote, path = m.group(1), m.group(2), m.group(3)
#             if path.startswith(('http://', 'https://', '//', '/', 'data:', '#', 'mailto:')):
#                 return m.group(0)
#             # Les chemins ../  ont déjà été résolus par MkDocs lors de la
#             # conversion Markdown→HTML (on_page_content reçoit du HTML fini).
#             # Les retraiter causerait une double transformation incorrecte.
#             if path.startswith('../'):
#                 return m.group(0)
#             if path.startswith('./'):
#                 docs_rel = posixpath.join(page_dir, path[2:]) if page_dir else path[2:]
#             else:
#                 docs_rel = posixpath.join(page_dir, path) if page_dir else path
#             site_rel = posixpath.relpath(docs_rel, page_out_dir)
#             return f'{attr}={quote}{site_rel}{quote}'

#         return re.sub(r'(src|href)=(["\'])([^"\']+)\2', rewrite, html_fragment, flags=re.IGNORECASE)

#     # ─────────────────────────────────────────────────────────────────────────
#     # HTML helpers
#     # ─────────────────────────────────────────────────────────────────────────

#     def _extract_marked(self, html: str, start_tag: str, end_tag: str):
#         start_re = re.compile(r'(?:<p>\s*)?' + re.escape(start_tag) + r'\s*(?:</p>)?', re.IGNORECASE)
#         end_re = re.compile(r'(?:<p>\s*)?' + re.escape(end_tag) + r'\s*(?:</p>)?', re.IGNORECASE)

#         sm = start_re.search(html)
#         if not sm:
#             return None, html
#         em = end_re.search(html, sm.end())
#         if not em:
#             return None, html

#         content = html[sm.end():em.start()].strip()
#         html_without = html[:sm.start()] + html[em.end():]
#         return (content or ''), html_without

#     def _replace_admonition(self, html: str, html_class: str, escaped_title: str, replacement: str) -> str:
#         open_pattern = re.compile(rf'<div\s+class="admonition\s+{html_class}"[^>]*>', re.IGNORECASE)
#         title_pattern = re.compile(rf'<p\s+class="admonition-title"[^>]*>\s*{escaped_title}\s*</p>', re.IGNORECASE)
#         div_token = re.compile(r'<(/?)div\b[^>]*>', re.IGNORECASE)

#         search_start = 0
#         while True:
#             open_m = open_pattern.search(html, search_start)
#             if not open_m:
#                 return html

#             after_open = html[open_m.end():open_m.end() + 300]
#             if not title_pattern.search(after_open):
#                 search_start = open_m.end()
#                 continue

#             adm_start = open_m.start()
#             depth = 1
#             pos = open_m.end()

#             while pos < len(html) and depth > 0:
#                 m = div_token.search(html, pos)
#                 if not m:
#                     break
#                 depth += -1 if m.group(1) == '/' else 1
#                 pos = m.end()

#             if depth != 0:
#                 return html

#             return html[:adm_start] + replacement + html[pos:]

#     # ─────────────────────────────────────────────────────────────────────────
#     # on_post_build
#     # ─────────────────────────────────────────────────────────────────────────

#     def on_post_build(self, config: MkDocsConfig) -> None:
#         site_dir = config['site_dir']
#         assets_output_dir = os.path.join(site_dir, 'assets', 'quiz')
#         os.makedirs(assets_output_dir, exist_ok=True)

#         for css_file in ['quiz-light.css', 'quiz-dark.css', 'unlock-page.css']:
#             src = self.assets_dir / css_file
#             if src.exists():
#                 shutil.copy(str(src), os.path.join(assets_output_dir, css_file))
#                 print(f"✅ Copied {css_file}")
#             else:
#                 print(f"❌ WARNING: {css_file} not found at {src}")

#         qrcode_src = self.assets_dir / 'qrcode.min.js'
#         if qrcode_src.exists():
#             shutil.copy(str(qrcode_src), os.path.join(assets_output_dir, 'qrcode.min.js'))
#             print(f"✅ Copied qrcode.min.js")
#         else:
#             print(f"❌ WARNING: qrcode.min.js not found")

#         quiz_js_src = self.assets_dir / 'quiz.js'
#         if not quiz_js_src.exists():
#             print(f"❌ ERROR: quiz.js not found at {quiz_js_src}")
#             return

#         with open(quiz_js_src, 'r', encoding='utf-8') as f:
#             quiz_js_content = f.read()

#         min_score_value = self.config.get('min_score')

#         config_injection = f'''// Quiz configuration (injected by plugin)
# window.QUIZ_CONFIG = window.QUIZ_CONFIG || {{}};
# window.QUIZ_CONFIG.teacher_code_hash = '{self.teacher_code_hash}';
# window.QUIZ_CONFIG.language = '{self.language}';
# window.QUIZ_CONFIG.i18n = {json.dumps(self.i18n.translations)};
# window.QUIZ_CONFIG.encryption_key = '{self.config.get('encryption_key', 'default-key-change-me')}';
# window.QUIZ_CONFIG.min_score = {json.dumps(min_score_value)};
# window.QUIZ_CONFIG.clamp_max_score = {json.dumps(self.config.get('clamp_max_score', True))};
# window.QUIZ_CONFIG.mcquiz = {json.dumps(self.config.get('mcquiz', {}))};
# window.QUIZ_CONFIG.scquiz = {json.dumps(self.config.get('scquiz', {}))};
# window.QUIZ_CONFIG.fillblanks = {json.dumps(self.config.get('fillblanks', {}))};
# window.QUIZ_CONFIG.scdropdown = {json.dumps(self.config.get('scdropdown', {}))};
# window.QUIZ_CONFIG.mcdropdown = {json.dumps(self.config.get('mcdropdown', {}))};
# window.QUIZ_CONFIG.orderquiz = {json.dumps(self.config.get('orderquiz', {}))};
# console.log('✅ QUIZ_CONFIG loaded:', window.QUIZ_CONFIG);

# '''
#         with open(os.path.join(assets_output_dir, 'quiz.js'), 'w', encoding='utf-8') as f:
#             f.write(config_injection + quiz_js_content)
#         print(f"✅ Created quiz.js with inline config")

#         quiz_unlock_src = self.assets_dir / 'quiz-unlock.js'
#         if quiz_unlock_src.exists():
#             shutil.copy(str(quiz_unlock_src), os.path.join(assets_output_dir, 'quiz-unlock.js'))
#             print(f"✅ Copied quiz-unlock.js")
#         else:
#             print(f"❌ WARNING: quiz-unlock.js not found")

#         unlock_page_src = self.templates_dir / 'quiz-unlock.html'
#         if unlock_page_src.exists():
#             with open(unlock_page_src, 'r', encoding='utf-8') as f:
#                 unlock_html = f.read()
#             unlock_html = unlock_html.replace('{{TEACHER_CODE_HASH}}', self.teacher_code_hash)
#             unlock_html = unlock_html.replace('{{I18N_TRANSLATIONS}}', json.dumps(self.i18n.translations))
#             with open(os.path.join(site_dir, 'quiz-unlock.html'), 'w', encoding='utf-8') as f:
#                 f.write(unlock_html)
#             print(f"✅ Created quiz-unlock.html")

#         if self.db:
#             self.db.close()

#     # ─────────────────────────────────────────────────────────────────────────
#     # on_post_page — inject CSS + JS directly into HTML output
#     # ─────────────────────────────────────────────────────────────────────────

#     def on_post_page(self, output: str, page: Page, config: MkDocsConfig) -> str:
#         if not hasattr(page, '_mkdocs_quiz_data'):
#             return output

#         page_url = (page.url or '').strip('/')
#         depth = page_url.count('/') + 1 if page_url else 0
#         prefix = '../' * depth

#         css_files = [
#             'assets/quiz/quiz-light.css',
#             'assets/quiz/quiz-dark.css',
#         ]
#         js_files = [
#             'assets/quiz/qrcode.min.js',
#             'assets/quiz/quiz.js',
#             'assets/quiz/quiz-unlock.js',
#         ]

#         css_tags = '\n'.join(
#             f'<link rel="stylesheet" href="{prefix}{f}">'
#             for f in css_files
#         )
#         js_tags = '\n'.join(
#             f'<script src="{prefix}{f}"></script>'
#             for f in js_files
#         )

#         if '</head>' in output and '</body>' in output:
#             output = output.replace('</head>', css_tags + '\n</head>', 1)
#             output = output.replace('</body>', js_tags + '\n</body>', 1)
#         elif '</body>' in output:
#             output = output.replace('</body>', css_tags + '\n' + js_tags + '\n</body>', 1)

#         return output

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

"""
Plugin principal MkDocs pour mkdocs-superquiz

Features in this version:
1) ✅ Fix repeated quiz titles:
   - If the same admonition title appears multiple times on a page,
     map title -> queue (deque) and consume quizzes in order.

2) ✅ Preamble markers are NOT "Q0":
   - Synthetic meta question 0 (is_meta=True / number==0) is rendered/extracted
     using dedicated PREAMBLE markers:
       <!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->
       ...
       <!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->

3) ✅ Config override levels:
   - Global (mkdocs.yml plugin config)   [lowest priority]
   - Page (front-matter `superquiz:` or `quiz:` block)   [overrides global]
   - Exercise (mini front-matter inside the quiz) [overrides page + global]

4) ✅ support "defaults" / "global" block in mkdocs.yml with rule:
   - NESTED > FLAT (at the same level)
   - i.e. defaults/global overrides root keys, including language.

5) ✅ on_post_page replaces on_page_context for reliable asset injection
   - Works with MkDocs Material and any theme
   - Depth-aware relative paths for nested pages

6) ✅ min_score semantics:
   - Default = 0 (no negative scores)
   - min_score: none / "none" / "null" → no minimum (negative scores allowed)
   - min_score: N → minimum N, but ignored if N >= points_max (aberrant case)
"""

import os
import re
import shutil
import json
import posixpath
from pathlib import Path
from collections import defaultdict, deque
from typing import Any, Dict

from mkdocs.config import config_options
from mkdocs.plugins import BasePlugin
from mkdocs.structure.files import Files
from mkdocs.structure.pages import Page
from mkdocs.config.defaults import MkDocsConfig

from .parser import QuizParser
from .renderer import QuizRenderer, _flatten_score_block
from .database import QuizDatabase
from .security import prepare_teacher_code_hash
from .config import DEFAULT_CONFIG, DEFAULTS_ALIASES
from .locale import QuizI18n


# ─────────────────────────────────────────────────────────────────────────────
# Admonition body-line detector  (must stay in sync with parser.py)
# ─────────────────────────────────────────────────────────────────────────────

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


# ─────────────────────────────────────────────────────────────────────────────
# Deep merge helpers
# ─────────────────────────────────────────────────────────────────────────────

def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
    """
    Recursively merge `override` into `base` and return a new dict.
    - dict values are merged recursively
    - other values replace the base value
    """
    if not isinstance(base, dict):
        base = {}
    if not isinstance(override, dict):
        override = {}

    out: Dict[str, Any] = dict(base)
    for k, v in override.items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = _deep_merge(out[k], v)
        else:
            out[k] = v
    return out


def _pick_quiz_type_config(cfg: Dict[str, Any], quiz_type: str) -> Dict[str, Any]:
    if not isinstance(cfg, dict):
        cfg = {}

    QUIZ_CFG_KEYS = {
        'show_correction', 'scoring_mode', 'default_false', 'default',
        'save_answers', 'randomize_answers', 'randomize_questions',
        'min_score', 'clamp_max_score', 'errors_mode', 'score', 'match',
    }

    result: Dict[str, Any] = dict(cfg)

    qt_cfg = cfg.get(quiz_type, {})
    if not isinstance(qt_cfg, dict):
        qt_cfg = {}
    qt_cfg = _flatten_score_block(qt_cfg)

    # Expandre randomize: dans qt_cfg si présent
    if 'randomize' in qt_cfg:
        from .locale import QuizI18n
        _i18n_tmp = QuizI18n('en')
        qt_cfg = dict(qt_cfg)
        qt_cfg.update(_i18n_tmp._expand_randomize(qt_cfg.pop('randomize')))

    cfg_flat = _flatten_score_block(cfg)
    # Expandre randomize: dans cfg_flat si présent
    if 'randomize' in cfg_flat:
        from .locale import QuizI18n
        _i18n_tmp = QuizI18n('en')
        cfg_flat = dict(cfg_flat)
        cfg_flat.update(_i18n_tmp._expand_randomize(cfg_flat.pop('randomize')))

    for k in QUIZ_CFG_KEYS:
        if k == 'score':
            continue
        if k in cfg_flat and cfg_flat[k] is not None and k not in qt_cfg:
            qt_cfg = dict(qt_cfg)
            qt_cfg[k] = cfg_flat[k]

    # ── FIX : orderquiz ajouté dans la liste des types initialisés ────────────
    for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz'):
        if qt not in result or not isinstance(result.get(qt), dict):
            result[qt] = {}

    result[quiz_type] = qt_cfg
    return result


def _normalize_exercise_config(ex_cfg: Dict[str, Any], quiz_type: str) -> Dict[str, Any]:
    """
    Accept both styles for exercise config:

    Flat:
        show_correction: on_demand
        scoring_mode: proportional
        default_false: 0
        compare_answers: { ... }   (fillblanks)

    Nested:
        mcquiz:
          show_correction: on_demand
    """
    if not isinstance(ex_cfg, dict):
        return {}

    if isinstance(ex_cfg.get(quiz_type), dict):
        return ex_cfg

    flat = dict(ex_cfg)
    out: Dict[str, Any] = {}

    global_keys = {
        'security_mode',
        'encryption_key',
        'api_endpoint',
        'correction_granularity',
        'correction_locked_icon',
        'correction_unlock_icon',
        'correction_unlocked_icon',
        'language',
        'min_score',
        'clamp_max_score',
    }
    for k in list(flat.keys()):
        if k in global_keys:
            out[k] = flat.pop(k)

    out[quiz_type] = flat
    return out


# ─────────────────────────────────────────────────────────────────────────────
# min_score normalization
# ─────────────────────────────────────────────────────────────────────────────

def _normalize_min_score(value: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, str) and value.lower() in ('none', 'null'):
        return None
    return value

# ─────────────────────────────────────────────────────────────────────────────
# bool_strings normalization
# ─────────────────────────────────────────────────────────────────────────────

def _normalize_bool_strings(cfg: dict, keys: tuple) -> dict:
    """
    YAML parses 'yes'/'no'/'true'/'false' as Python booleans.
    For string-semantic config keys (show_correction, scoring_mode, etc.),
    convert booleans back to their canonical string equivalents.
    """
    _BOOL_TO_STR = {True: 'yes', False: 'no'}
    if not isinstance(cfg, dict):
        return cfg
    result = dict(cfg)
    for key in keys:
        if key in result and isinstance(result[key], bool):
            result[key] = _BOOL_TO_STR[result[key]]
    return result

_STRING_BOOL_KEYS = (
    'show_correction', 'scoring_mode', 'default_false', 'save_answers',
    'randomize_questions', 'randomize_answers', 'errors_mode',
)


# ─────────────────────────────────────────────────────────────────────────────
# Plugin
# ─────────────────────────────────────────────────────────────────────────────

class QuizPlugin(BasePlugin):
    # ============= DO NOT REMOVE : BEGINNING OF MKDOCS-REVEALJS COMPATIBILITY SNIPPET : DO NOT REMOVE =====================
    # Declares the HTML marker patterns injected into admonition bodies during
    # on_page_markdown. mkdocs-revealjs uses these to identify admonitions it
    # must not re-render itself, and to delegate on_page_content processing
    # back to this plugin on the rendered slide HTML fragment.
    # Requires: superquiz declared BEFORE revealjs in mkdocs.yml plugins:
    REVEALJS_MANAGED_ADMONITIONS = [
        re.compile(r'<!--\s*MQS_\d+_', re.IGNORECASE),
    ]
    # ============= DO NOT REMOVE: END OF MKDOCS-COMPATIBILITY SNIPPET : DO NOT REMOVE =========================

    config_scheme = (
        ('language', config_options.Type(str, default='en')),

        ('defaults', config_options.Type(dict, default={})),
        ('global',   config_options.Type(dict, default={})),

        ('security_mode', config_options.Type(str, default='obfuscated')),
        ('teacher_code', config_options.Type(str, default=None)),
        ('encryption_key', config_options.Type(str, default='default-key-change-me')),
        ('api_endpoint', config_options.Type(str, default=None)),
        ('correction_granularity', config_options.Type(str, default='all')),

        ('min_score', config_options.Type((int, float, str), default=0)),
        ('clamp_max_score', config_options.Type(bool, default=True)),

        ('mcquiz',      config_options.Type(dict, default={})),
        ('scquiz',      config_options.Type(dict, default={})),
        ('fillblanks',  config_options.Type(dict, default={})),
        ('scdropdown',  config_options.Type(dict, default={})),
        ('mcdropdown',  config_options.Type(dict, default={})),
        ('orderquiz',   config_options.Type(dict, default={})),
        ('correction_locked_icon',   config_options.Type(str, default='❌')),
        ('correction_unlock_icon',   config_options.Type(str, default='🔐')),
        ('correction_unlocked_icon', config_options.Type(str, default='✅')),
    )

    def __init__(self):
        super().__init__()
        self.db = None
        self.teacher_code_hash = None
        self.i18n = None
        self.language = 'en'

        plugin_dir = Path(__file__).parent
        self.assets_dir = plugin_dir / 'assets'
        self.templates_dir = plugin_dir / 'templates'

    # ─────────────────────────────────────────────────────────────────────────
    # defaults/global handling (nested > flat)
    # ─────────────────────────────────────────────────────────────────────────

    def _extract_defaults_block(self, cfg: Dict[str, Any]) -> Dict[str, Any]:
        out: Dict[str, Any] = {}
        for k in DEFAULTS_ALIASES:
            block = cfg.get(k)
            if isinstance(block, dict) and block:
                out = _deep_merge(out, block)
        return out

    def _apply_nested_over_flat(self, raw_cfg: Dict[str, Any]) -> Dict[str, Any]:
        flat = dict(raw_cfg)
        for k in DEFAULTS_ALIASES:
            if k in flat:
                flat.pop(k, None)
        defaults_block = self._extract_defaults_block(raw_cfg)
        merged = _deep_merge(flat, defaults_block)
        return merged

    # ─────────────────────────────────────────────────────────────────────────
    # on_config
    # ─────────────────────────────────────────────────────────────────────────

    def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
        merged_cfg = self._apply_nested_over_flat(dict(self.config))
        self.config = merged_cfg

        self.config['min_score'] = _normalize_min_score(self.config.get('min_score'))

        self.language = self.config.get('language', 'en')

        from .renderer import _flatten_score_block as _fsb
        _i18n_tmp = QuizI18n(self.language)

        # Aplatir score: top-level + expandre randomize: top-level
        self.config = _fsb(self.config)
        if 'randomize' in self.config:
            self.config.update(_i18n_tmp._expand_randomize(self.config.pop('randomize')))

        PROPAGATABLE_KEYS = {
            'scoring_mode', 'default_false', 'default', 'show_correction',
            'save_answers', 'randomize', 'min_score', 'clamp_max_score',
        }
        top_level_flat = _fsb(self.config)

        for quiz_type in ['mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz']:
            user_type_config = self.config.get(quiz_type, {})
            if not isinstance(user_type_config, dict):
                user_type_config = {}
            user_type_config = _fsb(user_type_config)
            # Expandre randomize: dans le sous-dict type venant de mkdocs.yml
            if 'randomize' in user_type_config:
                user_type_config.update(_i18n_tmp._expand_randomize(user_type_config.pop('randomize')))

            # 1. Défauts depuis DEFAULT_CONFIG
            merged_type = dict(DEFAULT_CONFIG.get(quiz_type, {}))
            # Expandre randomize: dans DEFAULT_CONFIG (ex: randomize: 'none')
            if 'randomize' in merged_type:
                merged_type.update(_i18n_tmp._expand_randomize(merged_type.pop('randomize')))

            # 2. Top-level mkdocs.yml — plus spécifique que défauts
            # On ne propage PAS randomize directement : il a déjà été expandu
            # en randomize_answers/randomize_questions dans self.config top-level.
            # On propage uniquement les clés déjà expandues.
            for k in ('randomize_answers', 'randomize_questions'):
                if k in top_level_flat and top_level_flat[k] is not None:
                    merged_type[k] = top_level_flat[k]
            for k in PROPAGATABLE_KEYS - {'randomize'}:
                if k in top_level_flat and top_level_flat[k] is not None:
                    if k not in ('randomize_answers', 'randomize_questions'):
                        merged_type[k] = top_level_flat[k]

            # 3. Sous-dict type — le plus spécifique
            merged_type.update(user_type_config)
            self.config[quiz_type] = merged_type

        custom_synonyms = {qt: self.config.get(qt, {}) for qt in ['mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz']}
        self.i18n = QuizI18n(self.language, custom_synonyms)

        print(f"🌍 Language: {self.language}")
        print(f"   Recognized quiz type synonyms: {', '.join(self.i18n.get_all_quiz_type_synonyms())}")

        self.teacher_code_hash = prepare_teacher_code_hash(
            self.config.get('teacher_code') or 'CHANGE_ME'
        )

        db_path = os.path.join(config['docs_dir'], 'quizzes.db')
        self.db = QuizDatabase(db_path)

        return config

    # ─────────────────────────────────────────────────────────────────────────
    # Page-level overrides
    # ─────────────────────────────────────────────────────────────────────────

    def _get_page_overrides(self, page: Page) -> Dict[str, Any]:
        if not hasattr(page, 'meta') or not page.meta:
            return {}
        q = page.meta.get('superquiz') or page.meta.get('quiz')
        if not isinstance(q, dict):
            return {}
        # Normalize top-level bool strings
        q = _normalize_bool_strings(q, _STRING_BOOL_KEYS)
        # ── FIX : orderquiz ajouté dans la normalisation bool strings ─────────
        for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz'):
            if isinstance(q.get(qt), dict):
                q[qt] = _normalize_bool_strings(q[qt], _STRING_BOOL_KEYS)
        return q

    def _effective_config_for_quiz(self, page: Page, quiz: Dict[str, Any]) -> Dict[str, Any]:
        global_cfg = dict(self.config)
        page_cfg_raw = self._get_page_overrides(page)

        page_cfg = self.i18n.normalize_config_dict(page_cfg_raw) if page_cfg_raw else {}

        # Aplatir les blocs score: dans chaque sous-dict de type de quiz du page_cfg
        # avant le merge, pour que score:{mode:X} puisse écraser scoring_mode du global.
        from .renderer import _flatten_score_block as _fsb
        page_cfg_flat = dict(page_cfg)
        # ── FIX : orderquiz ajouté dans le flatten page_cfg ──────────────────
        for qt in ('mcquiz', 'scquiz', 'fillblanks', 'scdropdown', 'mcdropdown', 'orderquiz'):
            if isinstance(page_cfg_flat.get(qt), dict):
                page_cfg_flat[qt] = _fsb(page_cfg_flat[qt])
        # Aplatir aussi au top-level (si score: est directement dans page_cfg)
        page_cfg_flat = _fsb(page_cfg_flat)

        merged = _deep_merge(global_cfg, page_cfg_flat)

        quiz_type = quiz.get('type', '')
        ex_cfg_raw = quiz.get('config') or {}
        # Expandre randomize: dans le front-matter quiz avant normalisation
        if isinstance(ex_cfg_raw, dict) and 'randomize' in ex_cfg_raw:
            expanded = self.i18n._expand_randomize(ex_cfg_raw.pop('randomize'))
            ex_cfg_raw.update(expanded)
        ex_cfg = _normalize_exercise_config(ex_cfg_raw, quiz_type)
        merged = _deep_merge(merged, ex_cfg)
        merged = _pick_quiz_type_config(merged, quiz_type)

        merged['min_score'] = _normalize_min_score(merged.get('min_score'))

        return merged

    # ─────────────────────────────────────────────────────────────────────────
    # on_page_markdown
    # ─────────────────────────────────────────────────────────────────────────

    def on_page_markdown(self, markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str:
        parser = QuizParser(self.i18n, self.language)
        quizzes = parser.parse_markdown(markdown)

        if not quizzes:
            return markdown

        page_path = page.file.src_path
        page_title = page.title or page_path

        corrections_locked = False
        if hasattr(page, 'meta') and page.meta:
            show_correction = page.meta.get('quiz', {}).get('show_correction', 'on_demand')
            corrections_locked = (show_correction == 'on_demand')

        page_uuid = self.db.get_or_create_page(page_path, page_title, corrections_locked)
        self.db._clean_page_quizzes(page_uuid)

        for quiz in quizzes:
            quiz_db_id = self.db.add_quiz(page_uuid, quiz['number'], quiz['type'], quiz['title'])

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

                qid = self.db.add_question(quiz_db_id, question['number'], question.get('text', ''))

                if quiz['type'] in ['mcquiz', 'scquiz']:
                    for idx, answer in enumerate(question.get('answers', [])):
                        self.db.add_answer(qid, answer['text'], answer.get('is_correct', False), idx)

                elif quiz['type'] == 'fillblanks':
                    for blank_idx, blank in enumerate(question.get('blanks', [])):
                        for answer in blank.get('answers', []):
                            self.db.add_answer(qid, answer, True, blank_idx)

                elif quiz['type'] == 'scdropdown':
                    scdropdowns = question.get('scdropdowns') or []
                    if not scdropdowns:
                        dd = question.get('scdropdown')
                        scdropdowns = [dd] if dd else []
                    if scdropdowns:
                        dd = scdropdowns[0]
                        ci = dd.get('correct_index', 0)
                        for idx, option in enumerate(dd.get('options', [])):
                            self.db.add_answer(qid, option, idx == ci, idx)

                elif quiz['type'] == 'mcdropdown':
                    mcdropdowns = question.get('mcdropdowns') or []
                    for dd_idx, dd in enumerate(mcdropdowns):
                        if not dd:
                            continue
                        correct_indices = set(dd.get('correct_indices', []))
                        for idx, option in enumerate(dd.get('options', [])):
                            self.db.add_answer(qid, option, idx in correct_indices, idx)

                elif quiz['type'] == 'orderquiz':
                    for q_idx, item in enumerate(question.get('items', [])):
                        self.db.add_answer(qid, item, True, q_idx)

        modified_markdown = self._rewrite_admonitions(markdown, quizzes)

        if not hasattr(page, '_mkdocs_quiz_data'):
            page._mkdocs_quiz_data = {}
        page._mkdocs_quiz_data['quizzes'] = quizzes
        page._mkdocs_quiz_data['page_uuid'] = page_uuid
        page._mkdocs_quiz_data['page_path'] = page_path

        return modified_markdown

    # ─────────────────────────────────────────────────────────────────────────
    # Admonition rewriter
    # ─────────────────────────────────────────────────────────────────────────

    def _rewrite_admonitions(self, markdown: str, quizzes: list) -> str:
        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)

        quizzes_by_title = defaultdict(deque)
        for q in quizzes:
            quizzes_by_title[q['title']].append(q)

        src_lines = markdown.split('\n')
        out_lines = []
        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:
                title = hm.group(2)

                i += 1
                while i < len(src_lines) and _is_admonition_body_line(src_lines[i]):
                    i += 1

                out_lines.append(line)

                quiz = quizzes_by_title[title].popleft() if quizzes_by_title[title] else None
                if quiz:
                    out_lines.append(self._build_admonition_body(quiz))
            else:
                out_lines.append(line)
                i += 1

        return '\n'.join(out_lines)

    def _build_admonition_body(self, quiz: dict) -> str:
        quiz_num = quiz['number']
        quiz_type = quiz.get('type', '')
        indent = '    '
        blocks = []

        def strip_source_indent(raw: str) -> str:
            lines = raw.split('\n')
            indents = [
                len(ln) - len(ln.lstrip())
                for ln in lines
                if ln.strip() and ln[0] in (' ', '\t')
            ]
            min_indent = min(indents) if indents else 0
            result = []
            for ln in lines:
                if not ln.strip():
                    result.append('')
                elif ln[0] in (' ', '\t'):
                    result.append(ln[min_indent:])
                else:
                    result.append(ln)
            return '\n'.join(result)

        def ensure_blank(text: str) -> str:
            block_tags = (r'(?:div|iframe|figure|figcaption|table|thead|tbody|'
                          r'tr|th|td|blockquote|pre|p|h[1-6]|video|audio|details|summary)')
            self_closing = r'(?:img|br|hr|input|source)'

            text = re.sub(
                rf'(</{block_tags}\s*>)([ \t]*)$',
                r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
            )
            text = re.sub(
                rf'(<{self_closing}[^>]*/?>)([ \t]*)$',
                r'\1\2\n', text, flags=re.MULTILINE | re.IGNORECASE
            )

            lines = text.split('\n')
            result = []
            for line in lines:
                stripped = line.strip()
                is_list = (
                    (stripped and stripped[0] in ('*', '-', '+')
                     and len(stripped) > 1 and stripped[1] == ' ') or
                    bool(stripped and re.match(r'^\d+\.\s', stripped))
                )
                if is_list and result:
                    prev = result[-1].strip()
                    prev_is_list = (
                        (prev and prev[0] in ('*', '-', '+')
                         and len(prev) > 1 and prev[1] == ' ') or
                        bool(prev and re.match(r'^\d+\.\s', prev))
                    )
                    if prev and not prev_is_list:
                        result.append('')
                result.append(line)
            return '\n'.join(result)

        def build_rich_block(raw: str, start_marker: str, end_marker: str) -> str:
            cleaned = strip_source_indent(raw)
            cleaned = ensure_blank(cleaned)
            content_lines = [
                '' if ln.strip() == '' else f'{indent}{ln}'
                for ln in cleaned.split('\n')
            ]
            parts = ['', f'{indent}{start_marker}', ''] + content_lines + ['', f'{indent}{end_marker}', '']
            return '\n'.join(parts)

        for q in quiz.get('questions', []):
            q_num = q.get('number', 0)
            is_meta = bool(q.get('is_meta')) or (q_num == 0)

            # ── mcquiz / scquiz : émettre le texte de la question via marker ──
            if (quiz_type in ('mcquiz', 'scquiz')) and (not is_meta):
                q_text = (q.get('text') or '').strip()
                if q_text:
                    blocks.append('\n'.join([
                        '',
                        f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
                        '',
                        f'{indent}{q_text}',
                        '',
                        f'{indent}<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->',
                        '',
                    ]))

            # ── orderquiz : les items sont dans question['items'], pas de
            # text/rich_content. On émet un marqueur vide pour que l'admonition
            # MkDocs ne soit pas vide, afin que _replace_admonition() la trouve.
            if (quiz_type == 'orderquiz') and (not is_meta):
                blocks.append(f'\n{indent}<!-- MQS_{quiz_num}_ORDERQUIZ_Q{q_num} -->\n')
                continue  # pas de rich_content à émettre pour orderquiz

            # ── rich_content (images, iframes, markdown, html) ────────────────
            rich = q.get('rich_content', '') or ''
            rlines = rich.split('\n')
            while rlines and not rlines[0].strip():
                rlines.pop(0)
            while rlines and not rlines[-1].strip():
                rlines.pop()

            if not rlines:
                continue

            if is_meta:
                blocks.append(build_rich_block(
                    '\n'.join(rlines),
                    f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
                    f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
                ))
            else:
                blocks.append(build_rich_block(
                    '\n'.join(rlines),
                    f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
                    f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
                ))

        # ── FIX : renommage QUIZ_EMPTY → MQS_N_EMPTY (cohérence nommage) ──────
        if not blocks:
            return f'{indent}<!-- MQS_{quiz_num}_EMPTY -->\n'

        return '\n'.join(blocks) + '\n'

    # ─────────────────────────────────────────────────────────────────────────
    # on_page_content
    # ─────────────────────────────────────────────────────────────────────────

    def on_page_content(self, html: str, page: Page, config: MkDocsConfig, files: Files) -> str:
        if not hasattr(page, '_mkdocs_quiz_data'):
            return html

        quiz_data = page._mkdocs_quiz_data
        quizzes = quiz_data.get('quizzes', [])
        page_uuid = quiz_data.get('page_uuid')
        page_path = quiz_data.get('page_path', '')

        if not quizzes:
            return html

        modified_html = html

        for quiz in quizzes:
            quiz_num = quiz['number']
            quiz_type = quiz.get('type', '')

            for q in quiz.get('questions', []):
                q_num = q.get('number', 0)
                is_meta = bool(q.get('is_meta')) or (q_num == 0)

                # orderquiz : pas de text_html ni rich_content à extraire —
                # les items sont déjà dans question['items'], traités par le renderer.
                if quiz_type == 'orderquiz' and not is_meta:
                    continue

                if (quiz_type in ('mcquiz', 'scquiz')) and (not is_meta):
                    text_html, modified_html = self._extract_marked(
                        modified_html,
                        f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_START -->',
                        f'<!-- MQS_{quiz_num}_Q{q_num}_TEXT_END -->'
                    )
                    if text_html is not None:
                        p_count = len(re.findall(r'<p>', text_html, re.IGNORECASE))
                        if p_count == 1:
                            text_html = re.sub(
                                r'^\s*<p>(.*?)</p>\s*$',
                                r'\1',
                                text_html,
                                flags=re.DOTALL | re.IGNORECASE
                            )
                        q['text_html'] = self._fix_image_paths(text_html, page_path)

                if is_meta:
                    rich_html, modified_html = self._extract_marked(
                        modified_html,
                        f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_START -->',
                        f'<!-- MQS_{quiz_num}_QUIZ_PREAMBLE_END -->'
                    )
                else:
                    rich_html, modified_html = self._extract_marked(
                        modified_html,
                        f'<!-- MQS_{quiz_num}_Q{q_num}_START -->',
                        f'<!-- MQS_{quiz_num}_Q{q_num}_END -->'
                    )

                if rich_html is not None:
                    q['rich_content'] = self._fix_image_paths(rich_html, page_path)

            effective_cfg = self._effective_config_for_quiz(page, quiz)
            renderer = QuizRenderer(effective_cfg, page_uuid, self.teacher_code_hash, self.i18n, self.language)

            html_class = quiz.get('original_synonym', quiz['type']) or quiz['type']
            quiz_title = re.escape(quiz['title'])
            quiz_html = renderer.render_quiz(quiz)
            modified_html = self._replace_admonition(modified_html, html_class, quiz_title, quiz_html)

        return modified_html

    # ─────────────────────────────────────────────────────────────────────────
    # Image path fix
    # ─────────────────────────────────────────────────────────────────────────

    def _fix_image_paths(self, html_fragment: str, page_src_path: str) -> str:
        if not page_src_path:
            return html_fragment

        page_dir = posixpath.dirname(page_src_path)
        page_stem = posixpath.splitext(posixpath.basename(page_src_path))[0]
        page_out_dir = posixpath.join(page_dir, page_stem) if page_dir else page_stem

        def rewrite(m):
            attr, quote, path = m.group(1), m.group(2), m.group(3)
            if path.startswith(('http://', 'https://', '//', '/', 'data:', '#', 'mailto:')):
                return m.group(0)
            if path.startswith('../'):
                return m.group(0)
            if path.startswith('./'):
                docs_rel = posixpath.join(page_dir, path[2:]) if page_dir else path[2:]
            else:
                docs_rel = posixpath.join(page_dir, path) if page_dir else path
            site_rel = posixpath.relpath(docs_rel, page_out_dir)
            return f'{attr}={quote}{site_rel}{quote}'

        return re.sub(r'(src|href)=(["\'])([^"\']+)\2', rewrite, html_fragment, flags=re.IGNORECASE)

    # ─────────────────────────────────────────────────────────────────────────
    # HTML helpers
    # ─────────────────────────────────────────────────────────────────────────

    def _extract_marked(self, html: str, start_tag: str, end_tag: str):
        start_re = re.compile(r'(?:<p>\s*)?' + re.escape(start_tag) + r'\s*(?:</p>)?', re.IGNORECASE)
        end_re = re.compile(r'(?:<p>\s*)?' + re.escape(end_tag) + r'\s*(?:</p>)?', re.IGNORECASE)

        sm = start_re.search(html)
        if not sm:
            return None, html
        em = end_re.search(html, sm.end())
        if not em:
            return None, html

        content = html[sm.end():em.start()].strip()
        html_without = html[:sm.start()] + html[em.end():]
        return (content or ''), html_without

    def _replace_admonition(self, html: str, html_class: str, escaped_title: str, replacement: str) -> str:
        open_pattern = re.compile(rf'<div\s+class="admonition\s+{html_class}"[^>]*>', re.IGNORECASE)
        title_pattern = re.compile(rf'<p\s+class="admonition-title"[^>]*>\s*{escaped_title}\s*</p>', re.IGNORECASE)
        div_token = re.compile(r'<(/?)div\b[^>]*>', re.IGNORECASE)

        search_start = 0
        while True:
            open_m = open_pattern.search(html, search_start)
            if not open_m:
                return html

            after_open = html[open_m.end():open_m.end() + 300]
            if not title_pattern.search(after_open):
                search_start = open_m.end()
                continue

            adm_start = open_m.start()
            depth = 1
            pos = open_m.end()

            while pos < len(html) and depth > 0:
                m = div_token.search(html, pos)
                if not m:
                    break
                depth += -1 if m.group(1) == '/' else 1
                pos = m.end()

            if depth != 0:
                return html

            return html[:adm_start] + replacement + html[pos:]

    # ─────────────────────────────────────────────────────────────────────────
    # on_post_build
    # ─────────────────────────────────────────────────────────────────────────

    def on_post_build(self, config: MkDocsConfig) -> None:
        site_dir = config['site_dir']
        assets_output_dir = os.path.join(site_dir, 'assets', 'quiz')
        os.makedirs(assets_output_dir, exist_ok=True)

        for css_file in ['quiz-light.css', 'quiz-dark.css', 'unlock-page.css']:
            src = self.assets_dir / css_file
            if src.exists():
                shutil.copy(str(src), os.path.join(assets_output_dir, css_file))
                print(f"✅ Copied {css_file}")
            else:
                print(f"❌ WARNING: {css_file} not found at {src}")

        qrcode_src = self.assets_dir / 'qrcode.min.js'
        if qrcode_src.exists():
            shutil.copy(str(qrcode_src), os.path.join(assets_output_dir, 'qrcode.min.js'))
            print(f"✅ Copied qrcode.min.js")
        else:
            print(f"❌ WARNING: qrcode.min.js not found")

        quiz_js_src = self.assets_dir / 'quiz.js'
        if not quiz_js_src.exists():
            print(f"❌ ERROR: quiz.js not found at {quiz_js_src}")
            return

        with open(quiz_js_src, 'r', encoding='utf-8') as f:
            quiz_js_content = f.read()

        min_score_value = self.config.get('min_score')

        config_injection = f'''// Quiz configuration (injected by plugin)
window.QUIZ_CONFIG = window.QUIZ_CONFIG || {{}};
window.QUIZ_CONFIG.teacher_code_hash = '{self.teacher_code_hash}';
window.QUIZ_CONFIG.language = '{self.language}';
window.QUIZ_CONFIG.i18n = {json.dumps(self.i18n.translations)};
window.QUIZ_CONFIG.encryption_key = '{self.config.get('encryption_key', 'default-key-change-me')}';
window.QUIZ_CONFIG.min_score = {json.dumps(min_score_value)};
window.QUIZ_CONFIG.clamp_max_score = {json.dumps(self.config.get('clamp_max_score', True))};
window.QUIZ_CONFIG.mcquiz = {json.dumps(self.config.get('mcquiz', {}))};
window.QUIZ_CONFIG.scquiz = {json.dumps(self.config.get('scquiz', {}))};
window.QUIZ_CONFIG.fillblanks = {json.dumps(self.config.get('fillblanks', {}))};
window.QUIZ_CONFIG.scdropdown = {json.dumps(self.config.get('scdropdown', {}))};
window.QUIZ_CONFIG.mcdropdown = {json.dumps(self.config.get('mcdropdown', {}))};
window.QUIZ_CONFIG.orderquiz = {json.dumps(self.config.get('orderquiz', {}))};
console.log('✅ QUIZ_CONFIG loaded:', window.QUIZ_CONFIG);

'''
        with open(os.path.join(assets_output_dir, 'quiz.js'), 'w', encoding='utf-8') as f:
            f.write(config_injection + quiz_js_content)
        print(f"✅ Created quiz.js with inline config")

        quiz_unlock_src = self.assets_dir / 'quiz-unlock.js'
        if quiz_unlock_src.exists():
            shutil.copy(str(quiz_unlock_src), os.path.join(assets_output_dir, 'quiz-unlock.js'))
            print(f"✅ Copied quiz-unlock.js")
        else:
            print(f"❌ WARNING: quiz-unlock.js not found")

        unlock_page_src = self.templates_dir / 'quiz-unlock.html'
        if unlock_page_src.exists():
            with open(unlock_page_src, 'r', encoding='utf-8') as f:
                unlock_html = f.read()
            unlock_html = unlock_html.replace('{{TEACHER_CODE_HASH}}', self.teacher_code_hash)
            unlock_html = unlock_html.replace('{{I18N_TRANSLATIONS}}', json.dumps(self.i18n.translations))
            with open(os.path.join(site_dir, 'quiz-unlock.html'), 'w', encoding='utf-8') as f:
                f.write(unlock_html)
            print(f"✅ Created quiz-unlock.html")

        if self.db:
            self.db.close()

    # ─────────────────────────────────────────────────────────────────────────
    # on_post_page — inject CSS + JS directly into HTML output
    # ─────────────────────────────────────────────────────────────────────────

    def on_post_page(self, output: str, page: Page, config: MkDocsConfig) -> str:
        if not hasattr(page, '_mkdocs_quiz_data'):
            return output

        page_url = (page.url or '').strip('/')
        depth = page_url.count('/') + 1 if page_url else 0
        prefix = '../' * depth

        css_files = [
            'assets/quiz/quiz-light.css',
            'assets/quiz/quiz-dark.css',
        ]
        js_files = [
            'assets/quiz/qrcode.min.js',
            'assets/quiz/quiz.js',
            'assets/quiz/quiz-unlock.js',
        ]

        css_tags = '\n'.join(
            f'<link rel="stylesheet" href="{prefix}{f}">'
            for f in css_files
        )
        js_tags = '\n'.join(
            f'<script src="{prefix}{f}"></script>'
            for f in js_files
        )

        if '</head>' in output and '</body>' in output:
            output = output.replace('</head>', css_tags + '\n</head>', 1)
            output = output.replace('</body>', js_tags + '\n</body>', 1)
        elif '</body>' in output:
            output = output.replace('</body>', css_tags + '\n' + js_tags + '\n</body>', 1)

        return output

