# """
# 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 `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.
# """

# 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
# 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]:
#     """
#     Return a dict that represents ONLY the config for `quiz_type`, merged with
#     common/global keys present at top-level.
#     """
#     if not isinstance(cfg, dict):
#         cfg = {}

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

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

#     for qt in ('mcquiz', 'scquiz', 'gapfill', 'dropdown'):
#         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: { ... }   (gapfill)

#     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


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

# class QuizPlugin(BasePlugin):
#     # NOTE: MkDocs requires config_scheme to be static at import time.
#     config_scheme = (
#         ('language', config_options.Type(str, default='en')),

#         # ✅ optional defaults blocks (nested > flat)
#         ('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), default=None)),
#         ('clamp_max_score', config_options.Type(bool, default=True)),

#         ('mcquiz', config_options.Type(dict, default={})),
#         ('scquiz', config_options.Type(dict, default={})),
#         ('gapfill', config_options.Type(dict, default={})),
#         ('dropdown', 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]:
#         """
#         Returns the merged defaults block coming from any alias in DEFAULTS_ALIASES.
#         If both are present, the last one in DEFAULTS_ALIASES wins on conflicts.
#         """
#         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]:
#         """
#         Apply the rule NESTED > FLAT at plugin-global level:
#         - start with flat/root keys
#         - merge defaults/global over them
#         """
#         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.language = self.config.get('language', 'en')

#         # Merge DEFAULT_CONFIG into each quiz type config
#         for quiz_type in ['mcquiz', 'scquiz', 'gapfill', 'dropdown']:
#             default_type_config = DEFAULT_CONFIG.get(quiz_type, {})
#             user_type_config = self.config.get(quiz_type, {})
#             if not isinstance(user_type_config, dict):
#                 user_type_config = {}
#             self.config[quiz_type] = {**default_type_config, **user_type_config}

#         # i18n init
#         custom_synonyms = {qt: self.config.get(qt, {}) for qt in ['mcquiz', 'scquiz', 'gapfill', 'dropdown']}
#         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())}")

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

#         # DB (still used for page UUID + unlock granularity logic)
#         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('quiz')
#         return q if isinstance(q, dict) else {}

#     def _effective_config_for_quiz(self, page: Page, quiz: Dict[str, Any]) -> Dict[str, Any]:
#         """
#         Priority:
#           global(self.config) < page(front-matter) < exercise(quiz['config'])
#         """
#         global_cfg = dict(self.config)

#         page_cfg = self._get_page_overrides(page)
#         merged = _deep_merge(global_cfg, page_cfg)

#         quiz_type = quiz.get('type', '')
#         ex_cfg_raw = quiz.get('config') or {}
#         ex_cfg = _normalize_exercise_config(ex_cfg_raw, quiz_type)
#         merged = _deep_merge(merged, ex_cfg)

#         merged = _pick_quiz_type_config(merged, quiz_type)
#         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)

#         # Store only structure (not scores)
#         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'] == 'gapfill':
#                     for gap_idx, gap in enumerate(question.get('gaps', [])):
#                         for answer in gap.get('answers', []):
#                             self.db.add_answer(qid, answer, True, gap_idx)

#                 elif quiz['type'] == 'dropdown':
#                     # Keep compat: store options, mark correct index
#                     dropdowns = question.get('dropdowns') or []
#                     if not dropdowns:
#                         dd = question.get('dropdown')
#                         dropdowns = [dd] if dd else []
#                     if dropdowns:
#                         # store the first dropdown's options just like old code did
#                         dd = dropdowns[0]
#                         ci = dd.get('correct_index', 0)
#                         for idx, option in enumerate(dd.get('options', [])):
#                             self.db.add_answer(qid, option, idx == ci, 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'

#     # ─────────────────────────────────────────────────────────────────────────
#     # 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

#     # ─────────────────────────────────────────────────────────────────────────
#     # 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('./'):
#                 docs_rel = posixpath.join(page_dir, path[2:]) if page_dir else path[2:]
#             elif path.startswith('../'):
#                 parent = posixpath.dirname(page_dir) if page_dir else ''
#                 docs_rel = posixpath.join(parent, path[3:]) if parent else path[3:]
#             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()

#         # Inject global config for JS:
#         # - teacher_code_hash, language, i18n, encryption_key
#         # - min_score/clamp_max_score
#         # - per-type defaults (for backwards compatibility on compareAnswers)
#         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(self.config.get('min_score'))};
# 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.gapfill = {json.dumps(self.config.get('gapfill', {}))};
# window.QUIZ_CONFIG.dropdown = {json.dumps(self.config.get('dropdown', {}))};
# 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_page_context
#     # ─────────────────────────────────────────────────────────────────────────

#     # def on_page_context(self, context, page: Page, config: MkDocsConfig, nav) -> dict:
#     #     extra_css = list(context.get('extra_css', []))
#     #     extra_javascript = list(context.get('extra_javascript', []))

#     #     for css in ['assets/quiz/quiz-light.css', 'assets/quiz/quiz-dark.css']:
#     #         if css not in extra_css:
#     #             extra_css.append(css)

#     #     extra_javascript = [js for js in extra_javascript if not js.endswith(('qrcode.min.js', 'quiz.js'))]
#     #     extra_javascript.extend(['assets/quiz/qrcode.min.js', 'assets/quiz/quiz.js'])

#     #     context['extra_css'] = extra_css
#     #     context['extra_javascript'] = extra_javascript
#     #     return context

#     # ─────────────────────────────────────────────────────────────────────────────
#     # on_post_page — inject CSS + JS directly into HTML output (like mkdocs-revealjs)
#     # ─────────────────────────────────────────────────────────────────────────────

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

#         # Use MkDocs' own path resolution — same mechanism as extra_css/extra_javascript
#         # page.url is relative to site root, e.g. "suites/arithmetiques/"
#         # We compute the relative path back to site root from this page
#         page_url = (page.url or '').lstrip('/')
#         # Count directory depth: "suites/arithmetiques/" → 2 levels → "../../"
#         depth = page_url.rstrip('/').count('/') + (1 if page_url.rstrip('/') 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)
#         print(f"DEBUG page.url={page.url!r}  depth={depth}  prefix={prefix!r}")
#         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 `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
"""

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
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]:
    """
    Return a dict that represents ONLY the config for `quiz_type`, merged with
    common/global keys present at top-level.
    """
    if not isinstance(cfg, dict):
        cfg = {}

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

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

    for qt in ('mcquiz', 'scquiz', 'gapfill', 'dropdown'):
        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: { ... }   (gapfill)

    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


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

class QuizPlugin(BasePlugin):
    # NOTE: MkDocs requires config_scheme to be static at import time.
    config_scheme = (
        ('language', config_options.Type(str, default='en')),

        # ✅ optional defaults blocks (nested > flat)
        ('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), default=None)),
        ('clamp_max_score', config_options.Type(bool, default=True)),

        ('mcquiz', config_options.Type(dict, default={})),
        ('scquiz', config_options.Type(dict, default={})),
        ('gapfill', config_options.Type(dict, default={})),
        ('dropdown', 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.language = self.config.get('language', 'en')

        for quiz_type in ['mcquiz', 'scquiz', 'gapfill', 'dropdown']:
            default_type_config = DEFAULT_CONFIG.get(quiz_type, {})
            user_type_config = self.config.get(quiz_type, {})
            if not isinstance(user_type_config, dict):
                user_type_config = {}
            self.config[quiz_type] = {**default_type_config, **user_type_config}

        custom_synonyms = {qt: self.config.get(qt, {}) for qt in ['mcquiz', 'scquiz', 'gapfill', 'dropdown']}
        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('quiz')
        return q if isinstance(q, dict) else {}

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

        quiz_type = quiz.get('type', '')
        ex_cfg_raw = quiz.get('config') or {}
        ex_cfg = _normalize_exercise_config(ex_cfg_raw, quiz_type)
        merged = _deep_merge(merged, ex_cfg)
        merged = _pick_quiz_type_config(merged, quiz_type)
        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'] == 'gapfill':
                    for gap_idx, gap in enumerate(question.get('gaps', [])):
                        for answer in gap.get('answers', []):
                            self.db.add_answer(qid, answer, True, gap_idx)

                elif quiz['type'] == 'dropdown':
                    dropdowns = question.get('dropdowns') or []
                    if not dropdowns:
                        dd = question.get('dropdown')
                        dropdowns = [dd] if dd else []
                    if dropdowns:
                        dd = dropdowns[0]
                        ci = dd.get('correct_index', 0)
                        for idx, option in enumerate(dd.get('options', [])):
                            self.db.add_answer(qid, option, idx == ci, 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'

    # ─────────────────────────────────────────────────────────────────────────
    # 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

    # ─────────────────────────────────────────────────────────────────────────
    # 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('./'):
                docs_rel = posixpath.join(page_dir, path[2:]) if page_dir else path[2:]
            elif path.startswith('../'):
                parent = posixpath.dirname(page_dir) if page_dir else ''
                docs_rel = posixpath.join(parent, path[3:]) if parent else path[3:]
            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()

        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(self.config.get('min_score'))};
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.gapfill = {json.dumps(self.config.get('gapfill', {}))};
window.QUIZ_CONFIG.dropdown = {json.dumps(self.config.get('dropdown', {}))};
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
    # Replaces on_page_context which does not work reliably with MkDocs Material.
    # Uses depth-aware relative paths so assets resolve correctly from any
    # page nesting level (e.g. suites/arithmetiques/ → ../../assets/quiz/...)
    # ─────────────────────────────────────────────────────────────────────────

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

        # page.url example: "suites/arithmetiques/"  → depth=2 → prefix="../../"
        # page.url example: "ressources/quiz/"       → depth=2 → prefix="../../"
        # page.url example: ""  or  "/"              → depth=0 → prefix=""
        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
