# import json
# from pathlib import Path
# from typing import Any, Dict, List, Optional, Tuple


# class QuizI18n:
#     """Handles internationalization for the quiz plugin"""

#     def __init__(self, language: str = 'en', custom_synonyms: Optional[Dict] = None):
#         self.language = language
#         self.fallback_language = 'en'
#         self.custom_synonyms = custom_synonyms or {}
#         self.i18n_dir = Path(__file__).parent / 'i18n'
#         self.translations = self._load_translations()
#         self.fallback = self._load_fallback()
#         self.quiz_type_map = self._build_quiz_type_map()

#         # Build resolution maps for config keys and values
#         self._config_key_map: Dict[str, str] = {}   # synonym → canonical key
#         self._config_val_map: Dict[str, Dict[str, str]] = {}  # canonical_key → {synonym → canonical_value}
#         self._build_config_maps()

#     # ─────────────────────────────────────────────────────────────────────────
#     # Translation loading
#     # ─────────────────────────────────────────────────────────────────────────

#     def _load_translations(self) -> Dict:
#         translation_file = self.i18n_dir / f'{self.language}.json'
#         if not translation_file.exists():
#             print(f"⚠️  Translation file not found: {translation_file}")
#             print(f"   Falling back to {self.fallback_language}")
#             return {}
#         with open(translation_file, 'r', encoding='utf-8') as f:
#             return json.load(f)

#     def _load_fallback(self) -> Dict:
#         fallback_file = self.i18n_dir / f'{self.fallback_language}.json'
#         with open(fallback_file, 'r', encoding='utf-8') as f:
#             return json.load(f)

#     # ─────────────────────────────────────────────────────────────────────────
#     # Quiz type synonym map
#     # ─────────────────────────────────────────────────────────────────────────

#     def _build_quiz_type_map(self) -> Dict[str, str]:
#         mapping = {}
#         quiz_types = self.translations.get('quiz_types', self.fallback.get('quiz_types', {}))
#         for quiz_type, data in quiz_types.items():
#             for synonym in data.get('synonyms', []):
#                 if synonym in mapping:
#                     print(f"⚠️  Synonym conflict: '{synonym}' already maps to '{mapping[synonym]}'")
#                 else:
#                     mapping[synonym.lower()] = quiz_type

#             custom = self.custom_synonyms.get(quiz_type, {}).get('synonyms', '')
#             if custom:
#                 custom_list = [s.strip() for s in custom.split(',') if s.strip()]
#                 for synonym in custom_list:
#                     synonym_lower = synonym.lower()
#                     if synonym_lower in mapping:
#                         print(f"⚠️  Custom synonym '{synonym}' conflicts with existing mapping")
#                         print(f"   Skipping to avoid ambiguity")
#                     else:
#                         mapping[synonym_lower] = quiz_type
#                         print(f"✅ Added custom synonym: '{synonym}' -> '{quiz_type}'")
#         return mapping

#     # ─────────────────────────────────────────────────────────────────────────
#     # Config key/value resolution maps
#     # ─────────────────────────────────────────────────────────────────────────

#     # Canonical config keys that take a restricted set of values
#     # (other keys like min_score, default_false accept arbitrary numbers)
#     _VALUE_KEYS = {
#         'scoring_mode':        ('all_or_nothing', 'some_or_nothing', 'proportional'),
#         'show_correction':     ('yes', 'no', 'on_demand'),
#         'min_score':           ('none',),
#         'save_answers':        ('yes', 'no'),
#         'clamp_max_score':     ('yes', 'no'),
#         'randomize_answers':   ('yes', 'no'),
#         'randomize_questions': ('yes', 'no'),
#         # spaces values (used inside match.ignore.spaces)
#         'spaces':              ('spaces_all', 'spaces_inside', 'spaces_outside', 'spaces_none'),
#     }

#     def _build_config_maps(self) -> None:
#         """
#         Build two lookup dicts from en.json (always) + current lang JSON (if different).

#         _config_key_map : { synonym_lower → canonical_key }
#         _config_val_map : { canonical_key → { synonym_lower → canonical_value } }

#         Priority rule for key map:
#           1. Canonical flat keys always map to themselves (highest priority)
#           2. en.json synonyms
#           3. current lang synonyms (may add but not override canonical identities)

#         This ensures that old-syntax keys like "scoring_mode", "min_score",
#         "errors_mode", "compare_answers" always resolve to themselves, even if
#         a new-syntax alias entry (e.g. "mode": ["mode","scoring_mode"]) would
#         otherwise re-map "scoring_mode" → "mode".
#         """
#         key_map: Dict[str, str] = {}
#         val_map: Dict[str, Dict[str, str]] = {}

#         # Step 1: seed canonical flat keys as identity (highest priority)
#         # These are the keys that the rest of the code consumes downstream.
#         _CANONICAL_FLAT_KEYS = {
#             'scoring_mode', 'show_correction', 'min_score', 'clamp_max_score',
#             'default_false', 'errors_mode', 'compare_answers',
#             'save_answers', 'randomize_answers', 'randomize_questions',
#             'default_points', 'points',
#             # new-syntax block keys (resolve to themselves for structural dispatch)
#             'score', 'match',
#             # sub-keys used in decomposition
#             'mode', 'min', 'errors', 'ignore', 'equivalence',
#             'spaces', 'characters', 'case', 'diacritics',
#         }
#         for k in _CANONICAL_FLAT_KEYS:
#             key_map[k.lower()] = k

#         # Step 2: merge en.json then current lang (lower priority — don't override step 1)
#         sources: List[Dict] = [self.fallback]
#         if self.language != self.fallback_language and self.translations:
#             sources.append(self.translations)

#         for src in sources:
#             cfg = src.get('quiz_configs', {})
#             for canonical_key, synonyms in cfg.items():
#                 if not isinstance(synonyms, list):
#                     continue

#                 # Register synonyms → canonical_key, but never override an
#                 # already-seeded canonical identity from step 1
#                 for syn in synonyms:
#                     s = syn.lower()
#                     if s not in key_map:  # don't override canonical identities
#                         key_map[s] = canonical_key

#                 # Register as value synonyms for keys that accept this value
#                 for config_key, valid_values in self._VALUE_KEYS.items():
#                     if canonical_key in valid_values:
#                         if config_key not in val_map:
#                             val_map[config_key] = {}
#                         for syn in synonyms:
#                             val_map[config_key][syn.lower()] = canonical_key

#         self._config_key_map = key_map
#         self._config_val_map = val_map

#     # ─────────────────────────────────────────────────────────────────────────
#     # Public resolution API
#     # ─────────────────────────────────────────────────────────────────────────

#     def resolve_config_key(self, raw_key: str) -> str:
#         """
#         Resolve a (possibly translated/new-syntax) config key to its canonical name.

#         Examples:
#             "mode_evaluation"  → "scoring_mode"   (fr old syntax)
#             "scoring_mode"     → "scoring_mode"   (en identity)
#             "score"            → "score"           (new block key — handled by normalize_config_dict)
#             "match"            → "match"           (new block key — handled by normalize_config_dict)
#         """
#         return self._config_key_map.get(raw_key.lower(), raw_key)

#     def resolve_config_value(self, canonical_key: str, raw_value: Any) -> Any:
#         """
#         Resolve a (possibly translated) config value to its canonical English value.

#         - bool/int/float/None pass through unchanged
#         - strings are looked up in the value map for the given key
#         - 'yes'/'true'/'on' → True  (for boolean keys not in _VALUE_KEYS)
#         - 'no'/'false'/'off' → False

#         Examples (fr):
#             resolve_config_value("scoring_mode", "proportionnel") → "proportional"
#             resolve_config_value("show_correction", "oui")        → "yes"
#             resolve_config_value("min_score", "aucun")            → "none"
#             resolve_config_value("min_score", -1)                 → -1
#             resolve_config_value("spaces", "tous")                → "spaces_all"
#             resolve_config_value("case", True)                    → True   (bool passthrough)
#             resolve_config_value("case", "oui")                   → True   (fr bool)
#         """
#         # Bool values: if this key uses string values (e.g. show_correction: 'yes'/'no'),
#         # re-convert bool→string so the value map lookup works correctly.
#         # _coerce_scalar turns `no` → False before we get here, so we need to undo that
#         # for keys like show_correction that expect the string 'no', not the bool False.
#         if isinstance(raw_value, bool):
#             if canonical_key in self._VALUE_KEYS:
#                 raw_value = 'yes' if raw_value else 'no'
#             else:
#                 return raw_value

#         # Non-string, non-bool values pass through (already coerced by _coerce_scalar)
#         if not isinstance(raw_value, str):
#             return raw_value

#         v_lower = raw_value.lower()

#         # Check key-specific value map first
#         val_lookup = self._config_val_map.get(canonical_key, {})
#         if v_lower in val_lookup:
#             canonical_val = val_lookup[v_lower]
#             # spaces_all etc. → return as canonical string
#             return canonical_val

#         # Generic boolean coercion for boolean-typed config keys
#         # (keys not in _VALUE_KEYS but that accept yes/no/true/false)
#         _BOOL_KEYS = {
#             'case', 'diacritics', 'save_answers', 'clamp_max_score',
#             'randomize_answers', 'randomize_questions',
#         }
#         if canonical_key in _BOOL_KEYS:
#             if v_lower in ('yes', 'true', 'on', '1'):
#                 return True
#             if v_lower in ('no', 'false', 'off', '0'):
#                 return False

#         # Generic boolean coercion via i18n value map (yes/no canonical values)
#         # If "oui" maps to "yes" for show_correction, also apply for boolean keys
#         yes_syns = self._config_val_map.get('show_correction', {})
#         # find what "yes" maps to in show_correction — its synonyms are the yes-words
#         _yes_words = {s for s, c in yes_syns.items() if c == 'yes'}
#         _no_words  = {s for s, c in yes_syns.items() if c == 'no'}

#         if canonical_key in _BOOL_KEYS:
#             if v_lower in _yes_words:
#                 return True
#             if v_lower in _no_words:
#                 return False

#         return raw_value

#     # ─────────────────────────────────────────────────────────────────────────
#     # Structural decomposition helpers (new syntax)
#     # ─────────────────────────────────────────────────────────────────────────

#     def _resolve_subkey(self, block_name: str, raw_subkey: str) -> str:
#         """
#         Resolve a sub-key inside a `score` or `match` block.
#         Uses the same _config_key_map (sub-keys are registered there too).
#         """
#         return self._config_key_map.get(raw_subkey.lower(), raw_subkey)

#     def _decompose_score_block(self, score_dict: Dict[str, Any]) -> Dict[str, Any]:
#         """
#         Decompose a `score:` block into flat canonical keys.

#         score:
#           mode: "proportional"     → scoring_mode: "proportional"
#           min: 0                   → min_score: 0
#           errors:                  → errors_mode: {...rules...}
#             [Paris]: 1.5             + default_false: -1  (extracted from errors)
#             default_false: -1

#         Sub-keys are resolved via i18n then mapped to their flat canonical names.
#         """
#         out: Dict[str, Any] = {}

#         # Map from the canonical key name (as registered in _config_key_map)
#         # to the flat output key we want to produce.
#         # e.g. "mode" is registered in en.json under "mode": ["mode","scoring_mode"]
#         # so resolve_subkey("score","mode") → "mode"
#         # but we want the OUTPUT key to be "scoring_mode".
#         # Strategy: resolve the sub-key, then use this explicit map.
#         _SUBKEY_TO_FLAT: Dict[str, str] = {
#             'mode':          'scoring_mode',
#             'scoring_mode':  'scoring_mode',
#             'min':           'min_score',
#             'min_score':     'min_score',
#             'errors':        '_errors_block',
#             'errors_mode':   '_errors_block',
#             'default_false': 'default_false',
#             'default':       'default_false',
#         }

#         for raw_sub, raw_val in score_dict.items():
#             canon_sub = self._resolve_subkey('score', raw_sub)
#             flat_key = _SUBKEY_TO_FLAT.get(canon_sub, canon_sub)

#             if flat_key == 'scoring_mode':
#                 out['scoring_mode'] = self.resolve_config_value('scoring_mode', raw_val)

#             elif flat_key == 'min_score':
#                 out['min_score'] = self.resolve_config_value('min_score', raw_val)

#             elif flat_key == 'default_false':
#                 out['default_false'] = raw_val

#             elif flat_key == '_errors_block':
#                 # errors block: dict with rule-keys + optional default_false
#                 if isinstance(raw_val, dict):
#                     rules: Dict[str, Any] = {}
#                     for k, v in raw_val.items():
#                         # Resolve sub-keys of errors: default_false synonyms
#                         canon_k = self._resolve_subkey('errors', k)
#                         flat_k = _SUBKEY_TO_FLAT.get(canon_k, canon_k)
#                         if flat_k == 'default_false':
#                             out['default_false'] = v
#                         else:
#                             rules[k] = v  # preserve bracket-list keys as-is
#                     if rules:
#                         out['errors_mode'] = rules
#                 else:
#                     out['errors_mode'] = raw_val

#             else:
#                 # Unknown sub-key — pass through
#                 out[canon_sub] = raw_val

#         return out

#     def _decompose_match_block(self, match_dict: Dict[str, Any]) -> Dict[str, Any]:
#         """
#         Decompose a `match:` block into a canonical `compare_answers` dict.

#         match:
#           ignore:
#             spaces: all          → spaces: "spaces_all"
#             characters: "!?."
#             case: true
#             diacritics: true
#           equivalence:
#             a: ["á", "à"]

#         Result: { "compare_answers": { "ignore": {...}, "equivalence": {...} } }
#         """
#         ignore_out: Dict[str, Any] = {}
#         equiv_out: Dict[str, Any] = {}

#         for raw_sub, raw_val in match_dict.items():
#             canon_sub = self._resolve_subkey('match', raw_sub)

#             if canon_sub == 'ignore':
#                 if isinstance(raw_val, dict):
#                     for raw_ign_key, ign_val in raw_val.items():
#                         canon_ign = self._resolve_subkey('ignore', raw_ign_key)
#                         if canon_ign == 'spaces':
#                             # Resolve spaces value: "all"/"tous"/"todo" → "spaces_all" → strip prefix
#                             resolved = self.resolve_config_value('spaces', str(ign_val) if not isinstance(ign_val, str) else ign_val)
#                             # "spaces_all" → "all", "spaces_outside" → "outside", etc.
#                             if isinstance(resolved, str) and resolved.startswith('spaces_'):
#                                 resolved = resolved[len('spaces_'):]
#                             ignore_out['spaces'] = resolved
#                         elif canon_ign in ('case', 'diacritics'):
#                             ignore_out[canon_ign] = self.resolve_config_value(canon_ign, ign_val)
#                         elif canon_ign == 'characters':
#                             ignore_out['characters'] = ign_val
#                         else:
#                             ignore_out[canon_ign] = ign_val

#             elif canon_sub == 'equivalence':
#                 if isinstance(raw_val, dict):
#                     equiv_out = raw_val  # pass through — values are answer lists

#             else:
#                 # Unknown sub-key — pass through into ignore as best-effort
#                 pass

#         compare: Dict[str, Any] = {}
#         if ignore_out:
#             compare['ignore'] = ignore_out
#         if equiv_out:
#             compare['equivalence'] = equiv_out

#         return {'compare_answers': compare} if compare else {}

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

#     def normalize_config_dict(self, raw: Dict[str, Any]) -> Dict[str, Any]:
#         """
#         Normalize an entire config dict: resolve all keys and values.
#         Handles both old flat syntax and new structured syntax transparently.

#         OLD syntax (still works):
#             { "scoring_mode": "proportional", "show_correction": "yes",
#               "errors_mode": {"[Paris]": 1.5}, "default_false": -1,
#               "compare_answers": {"ignore": {"spaces": "all"}} }

#         NEW syntax:
#             { "score": {"mode": "proportional", "errors": {"[Paris]": 1.5, "default_false": -1}},
#               "show_correction": "yes",
#               "match": {"ignore": {"spaces": "all"}} }

#         MIXED syntax (also works):
#             { "score": {"mode": "proportional"}, "show_correction": "yes" }

#         i18n works at every level for both syntaxes.
#         Values without quotes work if _coerce_scalar already converted them
#         (e.g. `case: true` → bool True → passes through).
#         """
#         out: Dict[str, Any] = {}

#         for raw_key, raw_val in raw.items():
#             canon_key = self.resolve_config_key(raw_key)

#             # ── NEW SYNTAX: `score` block ──────────────────────────────────
#             if canon_key == 'score':
#                 if isinstance(raw_val, dict):
#                     decomposed = self._decompose_score_block(raw_val)
#                     out.update(decomposed)
#                 # else: malformed — ignore
#                 continue

#             # ── NEW SYNTAX: `match` block ──────────────────────────────────
#             if canon_key == 'match':
#                 if isinstance(raw_val, dict):
#                     decomposed = self._decompose_match_block(raw_val)
#                     out.update(decomposed)
#                 # else: malformed — ignore
#                 continue

#             # ── OLD SYNTAX: nested dict (errors_mode, compare_answers, etc.) ─
#             if isinstance(raw_val, dict):
#                 if canon_key == 'compare_answers':
#                     # Normalize ignore sub-keys and spaces values
#                     out[canon_key] = self._normalize_compare_answers_dict(raw_val)
#                 else:
#                     # errors_mode and other nested dicts: pass through as-is
#                     # (bracket-list keys are answer texts, not config keys)
#                     out[canon_key] = raw_val
#                 continue

#             # ── Scalar value ───────────────────────────────────────────────
#             canon_val = self.resolve_config_value(canon_key, raw_val)
#             out[canon_key] = canon_val

#         return out

#     def _normalize_compare_answers_dict(self, raw: Dict[str, Any]) -> Dict[str, Any]:
#         """
#         Normalize a compare_answers dict (old syntax):
#             { "ignore": { "spaces": "all", "case": true }, "equivalence": {...} }
#         Sub-keys of ignore are i18n-resolved; spaces values are normalized.
#         """
#         out: Dict[str, Any] = {}
#         for raw_sub, raw_val in raw.items():
#             canon_sub = self._resolve_subkey('compare_answers', raw_sub)
#             if canon_sub == 'ignore' and isinstance(raw_val, dict):
#                 ignore_out: Dict[str, Any] = {}
#                 for raw_ign, ign_val in raw_val.items():
#                     canon_ign = self._resolve_subkey('ignore', raw_ign)
#                     if canon_ign == 'spaces':
#                         resolved = self.resolve_config_value('spaces', str(ign_val) if not isinstance(ign_val, str) else ign_val)
#                         if isinstance(resolved, str) and resolved.startswith('spaces_'):
#                             resolved = resolved[len('spaces_'):]
#                         ignore_out['spaces'] = resolved
#                     elif canon_ign in ('case', 'diacritics'):
#                         ignore_out[canon_ign] = self.resolve_config_value(canon_ign, ign_val)
#                     elif canon_ign == 'characters':
#                         ignore_out['characters'] = ign_val
#                     else:
#                         ignore_out[canon_ign] = ign_val
#                 out['ignore'] = ignore_out
#             elif canon_sub == 'equivalence':
#                 out['equivalence'] = raw_val  # pass through
#             else:
#                 out[canon_sub] = raw_val
#         return out

#     # ─────────────────────────────────────────────────────────────────────────
#     # Keywords for parser (preamble / question / answers section headers)
#     # ─────────────────────────────────────────────────────────────────────────

#     def get_parser_keywords(self) -> Dict[str, List[str]]:
#         """
#         Return merged keyword lists for parser use.
#         Always includes English keywords; adds current-lang keywords if different.
#         """
#         en_cfg = self.fallback.get('quiz_configs', {})
#         lang_cfg = self.translations.get('quiz_configs', {}) if self.language != self.fallback_language else {}

#         def merge_lists(key: str) -> List[str]:
#             en_list = en_cfg.get(key, [])
#             lang_list = lang_cfg.get(key, [])
#             combined = list(en_list)
#             for item in lang_list:
#                 if item not in combined:
#                     combined.append(item)
#             return combined

#         return {
#             'preamble': merge_lists('preamble_keywords'),
#             'question': merge_lists('question_keywords'),
#             'answers':  merge_lists('answers_keywords'),
#         }

#     # ─────────────────────────────────────────────────────────────────────────
#     # Quiz type helpers
#     # ─────────────────────────────────────────────────────────────────────────

#     def get_quiz_type(self, synonym: str) -> Optional[str]:
#         return self.quiz_type_map.get(synonym.lower())

#     def get_quiz_synonyms(self, quiz_type: str) -> List[str]:
#         quiz_types = self.translations.get('quiz_types', self.fallback.get('quiz_types', {}))
#         return quiz_types.get(quiz_type, {}).get('synonyms', [])

#     def get_all_quiz_type_synonyms(self) -> List[str]:
#         return list(self.quiz_type_map.keys())

#     # ─────────────────────────────────────────────────────────────────────────
#     # Translation helper
#     # ─────────────────────────────────────────────────────────────────────────

#     def t(self, key: str, fallback: str = '') -> str:
#         keys = key.split('.')
#         value = self.translations
#         for k in keys:
#             if isinstance(value, dict):
#                 value = value.get(k)
#             else:
#                 value = None
#                 break
#         if value is not None:
#             return str(value)

#         value = self.fallback
#         for k in keys:
#             if isinstance(value, dict):
#                 value = value.get(k)
#             else:
#                 value = None
#                 break
#         if value is not None:
#             return str(value)

#         return fallback or key

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

import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple


class QuizI18n:
    """Handles internationalization for the quiz plugin"""

    def __init__(self, language: str = 'en', custom_synonyms: Optional[Dict] = None):
        self.language = language
        self.fallback_language = 'en'
        self.custom_synonyms = custom_synonyms or {}
        self.i18n_dir = Path(__file__).parent / 'i18n'
        self.translations = self._load_translations()
        self.fallback = self._load_fallback()
        self.quiz_type_map = self._build_quiz_type_map()

        # Build resolution maps for config keys and values
        self._config_key_map: Dict[str, str] = {}   # synonym → canonical key
        self._config_val_map: Dict[str, Dict[str, str]] = {}  # canonical_key → {synonym → canonical_value}
        self._build_config_maps()

    # ─────────────────────────────────────────────────────────────────────────
    # Translation loading
    # ─────────────────────────────────────────────────────────────────────────

    def _load_translations(self) -> Dict:
        translation_file = self.i18n_dir / f'{self.language}.json'
        if not translation_file.exists():
            print(f"⚠️  Translation file not found: {translation_file}")
            print(f"   Falling back to {self.fallback_language}")
            return {}
        with open(translation_file, 'r', encoding='utf-8') as f:
            return json.load(f)

    def _load_fallback(self) -> Dict:
        fallback_file = self.i18n_dir / f'{self.fallback_language}.json'
        with open(fallback_file, 'r', encoding='utf-8') as f:
            return json.load(f)

    # ─────────────────────────────────────────────────────────────────────────
    # Quiz type synonym map
    # ─────────────────────────────────────────────────────────────────────────

    def _build_quiz_type_map(self) -> Dict[str, str]:
        mapping = {}
        quiz_types = self.translations.get('quiz_types', self.fallback.get('quiz_types', {}))
        for quiz_type, data in quiz_types.items():
            for synonym in data.get('synonyms', []):
                if synonym in mapping:
                    print(f"⚠️  Synonym conflict: '{synonym}' already maps to '{mapping[synonym]}'")
                else:
                    mapping[synonym.lower()] = quiz_type

            custom = self.custom_synonyms.get(quiz_type, {}).get('synonyms', '')
            if custom:
                custom_list = [s.strip() for s in custom.split(',') if s.strip()]
                for synonym in custom_list:
                    synonym_lower = synonym.lower()
                    if synonym_lower in mapping:
                        print(f"⚠️  Custom synonym '{synonym}' conflicts with existing mapping")
                        print(f"   Skipping to avoid ambiguity")
                    else:
                        mapping[synonym_lower] = quiz_type
                        print(f"✅ Added custom synonym: '{synonym}' -> '{quiz_type}'")
        return mapping

    # ─────────────────────────────────────────────────────────────────────────
    # Config key/value resolution maps
    # ─────────────────────────────────────────────────────────────────────────

    # Canonical config keys that take a restricted set of values
    # (other keys like min_score, default_false accept arbitrary numbers)
    _VALUE_KEYS = {
        'scoring_mode':        ('all_or_nothing', 'some_or_nothing', 'proportional'),
        'show_correction':     ('yes', 'no', 'on_demand'),
        'min_score':           ('none',),
        'save_answers':        ('yes', 'no'),
        'clamp_max_score':     ('yes', 'no'),
        'randomize':           ('randomize_answers', 'randomize_questions', 'randomize_all', 'randomize_none'),
        # spaces values (used inside match.ignore.spaces)
        'spaces':              ('spaces_all', 'spaces_inside', 'spaces_outside', 'spaces_none'),
    }

    def _build_config_maps(self) -> None:
        """
        Build two lookup dicts from en.json (always) + current lang JSON (if different).

        _config_key_map : { synonym_lower → canonical_key }
        _config_val_map : { canonical_key → { synonym_lower → canonical_value } }

        Priority rule for key map:
          1. Canonical flat keys always map to themselves (highest priority)
          2. en.json synonyms
          3. current lang synonyms (may add but not override canonical identities)

        This ensures that old-syntax keys like "scoring_mode", "min_score",
        "errors_mode", "compare_answers" always resolve to themselves, even if
        a new-syntax alias entry (e.g. "mode": ["mode","scoring_mode"]) would
        otherwise re-map "scoring_mode" → "mode".
        """
        key_map: Dict[str, str] = {}
        val_map: Dict[str, Dict[str, str]] = {}

        # Step 1: seed canonical flat keys as identity (highest priority)
        # These are the keys that the rest of the code consumes downstream.
        _CANONICAL_FLAT_KEYS = {
            'scoring_mode', 'show_correction', 'min_score', 'clamp_max_score',
            'default_false', 'errors_mode', 'compare_answers',
            'save_answers', 'randomize',
            'default_points', 'points',
            # new-syntax block keys (resolve to themselves for structural dispatch)
            'score', 'match',
            # sub-keys used in decomposition
            'mode', 'min', 'errors', 'ignore', 'equivalence',
            'spaces', 'characters', 'case', 'diacritics',
        }
        for k in _CANONICAL_FLAT_KEYS:
            key_map[k.lower()] = k

        # Step 2: merge en.json then current lang (lower priority — don't override step 1)
        sources: List[Dict] = [self.fallback]
        if self.language != self.fallback_language and self.translations:
            sources.append(self.translations)

        for src in sources:
            cfg = src.get('quiz_configs', {})
            for canonical_key, synonyms in cfg.items():
                if not isinstance(synonyms, list):
                    continue

                # Register synonyms → canonical_key, but never override an
                # already-seeded canonical identity from step 1
                for syn in synonyms:
                    s = syn.lower()
                    if s not in key_map:  # don't override canonical identities
                        key_map[s] = canonical_key

                # Register as value synonyms for keys that accept this value
                for config_key, valid_values in self._VALUE_KEYS.items():
                    if canonical_key in valid_values:
                        if config_key not in val_map:
                            val_map[config_key] = {}
                        for syn in synonyms:
                            val_map[config_key][syn.lower()] = canonical_key

        self._config_key_map = key_map
        self._config_val_map = val_map

    # ─────────────────────────────────────────────────────────────────────────
    # Public resolution API
    # ─────────────────────────────────────────────────────────────────────────

    def resolve_config_key(self, raw_key: str) -> str:
        """
        Resolve a (possibly translated/new-syntax) config key to its canonical name.

        Examples:
            "mode_evaluation"  → "scoring_mode"   (fr old syntax)
            "scoring_mode"     → "scoring_mode"   (en identity)
            "score"            → "score"           (new block key — handled by normalize_config_dict)
            "match"            → "match"           (new block key — handled by normalize_config_dict)
        """
        return self._config_key_map.get(raw_key.lower(), raw_key)

    def resolve_config_value(self, canonical_key: str, raw_value: Any) -> Any:
        """
        Resolve a (possibly translated) config value to its canonical English value.

        - bool/int/float/None pass through unchanged
        - strings are looked up in the value map for the given key
        - 'yes'/'true'/'on' → True  (for boolean keys not in _VALUE_KEYS)
        - 'no'/'false'/'off' → False

        Examples (fr):
            resolve_config_value("scoring_mode", "proportionnel") → "proportional"
            resolve_config_value("show_correction", "oui")        → "yes"
            resolve_config_value("min_score", "aucun")            → "none"
            resolve_config_value("min_score", -1)                 → -1
            resolve_config_value("spaces", "tous")                → "spaces_all"
            resolve_config_value("case", True)                    → True   (bool passthrough)
            resolve_config_value("case", "oui")                   → True   (fr bool)
        """
        # Bool values: if this key uses string values (e.g. show_correction: 'yes'/'no'),
        # re-convert bool→string so the value map lookup works correctly.
        # _coerce_scalar turns `no` → False before we get here, so we need to undo that
        # for keys like show_correction that expect the string 'no', not the bool False.
        if isinstance(raw_value, bool):
            if canonical_key in self._VALUE_KEYS:
                raw_value = 'yes' if raw_value else 'no'
            else:
                return raw_value

        # Non-string, non-bool values pass through (already coerced by _coerce_scalar)
        if not isinstance(raw_value, str):
            return raw_value

        v_lower = raw_value.lower()

        # Check key-specific value map first
        val_lookup = self._config_val_map.get(canonical_key, {})
        if v_lower in val_lookup:
            canonical_val = val_lookup[v_lower]
            # spaces_all etc. → return as canonical string
            return canonical_val

        # Generic boolean coercion for boolean-typed config keys
        # (keys not in _VALUE_KEYS but that accept yes/no/true/false)
        _BOOL_KEYS = {
            'case', 'diacritics', 'save_answers', 'clamp_max_score',
        }
        if canonical_key in _BOOL_KEYS:
            if v_lower in ('yes', 'true', 'on', '1'):
                return True
            if v_lower in ('no', 'false', 'off', '0'):
                return False

        # Generic boolean coercion via i18n value map (yes/no canonical values)
        # If "oui" maps to "yes" for show_correction, also apply for boolean keys
        yes_syns = self._config_val_map.get('show_correction', {})
        # find what "yes" maps to in show_correction — its synonyms are the yes-words
        _yes_words = {s for s, c in yes_syns.items() if c == 'yes'}
        _no_words  = {s for s, c in yes_syns.items() if c == 'no'}

        if canonical_key in _BOOL_KEYS:
            if v_lower in _yes_words:
                return True
            if v_lower in _no_words:
                return False

        return raw_value

    # ─────────────────────────────────────────────────────────────────────────
    # Structural decomposition helpers (new syntax)
    # ─────────────────────────────────────────────────────────────────────────

    def _resolve_subkey(self, block_name: str, raw_subkey: str) -> str:
        """
        Resolve a sub-key inside a `score` or `match` block.
        Uses the same _config_key_map (sub-keys are registered there too).
        """
        return self._config_key_map.get(raw_subkey.lower(), raw_subkey)

    def _decompose_score_block(self, score_dict: Dict[str, Any]) -> Dict[str, Any]:
        """
        Decompose a `score:` block into flat canonical keys.

        score:
          mode: "proportional"     → scoring_mode: "proportional"
          min: 0                   → min_score: 0
          errors:                  → errors_mode: {...rules...}
            [Paris]: 1.5             + default_false: -1  (extracted from errors)
            default_false: -1

        Sub-keys are resolved via i18n then mapped to their flat canonical names.
        """
        out: Dict[str, Any] = {}

        # Map from the canonical key name (as registered in _config_key_map)
        # to the flat output key we want to produce.
        # e.g. "mode" is registered in en.json under "mode": ["mode","scoring_mode"]
        # so resolve_subkey("score","mode") → "mode"
        # but we want the OUTPUT key to be "scoring_mode".
        # Strategy: resolve the sub-key, then use this explicit map.
        _SUBKEY_TO_FLAT: Dict[str, str] = {
            'mode':          'scoring_mode',
            'scoring_mode':  'scoring_mode',
            'min':           'min_score',
            'min_score':     'min_score',
            'errors':        '_errors_block',
            'errors_mode':   '_errors_block',
            'default_false': 'default_false',
            'default':       'default_false',
        }

        for raw_sub, raw_val in score_dict.items():
            canon_sub = self._resolve_subkey('score', raw_sub)
            flat_key = _SUBKEY_TO_FLAT.get(canon_sub, canon_sub)

            if flat_key == 'scoring_mode':
                out['scoring_mode'] = self.resolve_config_value('scoring_mode', raw_val)

            elif flat_key == 'min_score':
                out['min_score'] = self.resolve_config_value('min_score', raw_val)

            elif flat_key == 'default_false':
                out['default_false'] = raw_val

            elif flat_key == '_errors_block':
                # errors block: dict with rule-keys + optional default_false
                if isinstance(raw_val, dict):
                    rules: Dict[str, Any] = {}
                    for k, v in raw_val.items():
                        # Resolve sub-keys of errors: default_false synonyms
                        canon_k = self._resolve_subkey('errors', k)
                        flat_k = _SUBKEY_TO_FLAT.get(canon_k, canon_k)
                        if flat_k == 'default_false':
                            out['default_false'] = v
                        else:
                            rules[k] = v  # preserve bracket-list keys as-is
                    if rules:
                        out['errors_mode'] = rules
                else:
                    out['errors_mode'] = raw_val

            else:
                # Unknown sub-key — pass through
                out[canon_sub] = raw_val

        return out

    def _decompose_match_block(self, match_dict: Dict[str, Any]) -> Dict[str, Any]:
        """
        Decompose a `match:` block into a canonical `compare_answers` dict.

        match:
          ignore:
            spaces: all          → spaces: "spaces_all"
            characters: "!?."
            case: true
            diacritics: true
          equivalence:
            a: ["á", "à"]

        Result: { "compare_answers": { "ignore": {...}, "equivalence": {...} } }
        """
        ignore_out: Dict[str, Any] = {}
        equiv_out: Dict[str, Any] = {}

        for raw_sub, raw_val in match_dict.items():
            canon_sub = self._resolve_subkey('match', raw_sub)

            if canon_sub == 'ignore':
                if isinstance(raw_val, dict):
                    for raw_ign_key, ign_val in raw_val.items():
                        canon_ign = self._resolve_subkey('ignore', raw_ign_key)
                        if canon_ign == 'spaces':
                            # Resolve spaces value: "all"/"tous"/"todo" → "spaces_all" → strip prefix
                            resolved = self.resolve_config_value('spaces', str(ign_val) if not isinstance(ign_val, str) else ign_val)
                            # "spaces_all" → "all", "spaces_outside" → "outside", etc.
                            if isinstance(resolved, str) and resolved.startswith('spaces_'):
                                resolved = resolved[len('spaces_'):]
                            ignore_out['spaces'] = resolved
                        elif canon_ign in ('case', 'diacritics'):
                            ignore_out[canon_ign] = self.resolve_config_value(canon_ign, ign_val)
                        elif canon_ign == 'characters':
                            ignore_out['characters'] = ign_val
                        else:
                            ignore_out[canon_ign] = ign_val

            elif canon_sub == 'equivalence':
                if isinstance(raw_val, dict):
                    equiv_out = raw_val  # pass through — values are answer lists

            else:
                # Unknown sub-key — pass through into ignore as best-effort
                pass

        compare: Dict[str, Any] = {}
        if ignore_out:
            compare['ignore'] = ignore_out
        if equiv_out:
            compare['equivalence'] = equiv_out

        return {'compare_answers': compare} if compare else {}

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

    def normalize_config_dict(self, raw: Dict[str, Any]) -> Dict[str, Any]:
        """
        Normalize an entire config dict: resolve all keys and values.
        Handles both old flat syntax and new structured syntax transparently.

        OLD syntax (still works):
            { "scoring_mode": "proportional", "show_correction": "yes",
              "errors_mode": {"[Paris]": 1.5}, "default_false": -1,
              "compare_answers": {"ignore": {"spaces": "all"}} }

        NEW syntax:
            { "score": {"mode": "proportional", "errors": {"[Paris]": 1.5, "default_false": -1}},
              "show_correction": "yes",
              "match": {"ignore": {"spaces": "all"}} }

        MIXED syntax (also works):
            { "score": {"mode": "proportional"}, "show_correction": "yes" }

        i18n works at every level for both syntaxes.
        Values without quotes work if _coerce_scalar already converted them
        (e.g. `case: true` → bool True → passes through).
        """
        out: Dict[str, Any] = {}

        for raw_key, raw_val in raw.items():
            canon_key = self.resolve_config_key(raw_key)

            # ── NEW SYNTAX: `score` block ──────────────────────────────────
            if canon_key == 'score':
                if isinstance(raw_val, dict):
                    decomposed = self._decompose_score_block(raw_val)
                    out.update(decomposed)
                # else: malformed — ignore
                continue

            # ── NEW SYNTAX: `match` block ──────────────────────────────────
            if canon_key == 'match':
                if isinstance(raw_val, dict):
                    decomposed = self._decompose_match_block(raw_val)
                    out.update(decomposed)
                # else: malformed — ignore
                continue

            # ── OLD SYNTAX: nested dict (errors_mode, compare_answers, etc.) ─
            if isinstance(raw_val, dict):
                if canon_key == 'compare_answers':
                    # Normalize ignore sub-keys and spaces values
                    out[canon_key] = self._normalize_compare_answers_dict(raw_val)
                else:
                    # errors_mode and other nested dicts: pass through as-is
                    # (bracket-list keys are answer texts, not config keys)
                    out[canon_key] = raw_val
                continue

            # ── randomize key — expand to randomize_answers / randomize_questions ──
            if canon_key == 'randomize':
                expanded = self._expand_randomize(raw_val)
                out.update(expanded)
                continue

            # ── Scalar value ───────────────────────────────────────────────
            canon_val = self.resolve_config_value(canon_key, raw_val)
            out[canon_key] = canon_val

        return out

    def _expand_randomize(self, raw_val: Any) -> Dict[str, bool]:
        """
        Expand   randomize: <value>   into the two booleans consumed downstream.

        Canonical values (after i18n resolution):
            'randomize_answers'   → randomize_answers=True,  randomize_questions=False
            'randomize_questions' → randomize_answers=False, randomize_questions=True
            'randomize_all'       → randomize_answers=True,  randomize_questions=True
            'randomize_none'      → randomize_answers=False, randomize_questions=False

        Also accepts already-canonical strings and booleans (bool True = 'all').
        """
        canon = self.resolve_config_value('randomize', raw_val) if isinstance(raw_val, str) else raw_val

        if canon == 'randomize_answers':
            return {'randomize_answers': True,  'randomize_questions': False}
        if canon == 'randomize_questions':
            return {'randomize_answers': False, 'randomize_questions': True}
        if canon == 'randomize_all' or canon is True:
            return {'randomize_answers': True,  'randomize_questions': True}
        if canon == 'randomize_none' or canon is False:
            return {'randomize_answers': False, 'randomize_questions': False}
        # Fallback : valeur non reconnue → pas de randomisation
        return {'randomize_answers': False, 'randomize_questions': False}

    def _normalize_compare_answers_dict(self, raw: Dict[str, Any]) -> Dict[str, Any]:
        """
        Normalize a compare_answers dict (old syntax):
            { "ignore": { "spaces": "all", "case": true }, "equivalence": {...} }
        Sub-keys of ignore are i18n-resolved; spaces values are normalized.
        """
        out: Dict[str, Any] = {}
        for raw_sub, raw_val in raw.items():
            canon_sub = self._resolve_subkey('compare_answers', raw_sub)
            if canon_sub == 'ignore' and isinstance(raw_val, dict):
                ignore_out: Dict[str, Any] = {}
                for raw_ign, ign_val in raw_val.items():
                    canon_ign = self._resolve_subkey('ignore', raw_ign)
                    if canon_ign == 'spaces':
                        resolved = self.resolve_config_value('spaces', str(ign_val) if not isinstance(ign_val, str) else ign_val)
                        if isinstance(resolved, str) and resolved.startswith('spaces_'):
                            resolved = resolved[len('spaces_'):]
                        ignore_out['spaces'] = resolved
                    elif canon_ign in ('case', 'diacritics'):
                        ignore_out[canon_ign] = self.resolve_config_value(canon_ign, ign_val)
                    elif canon_ign == 'characters':
                        ignore_out['characters'] = ign_val
                    else:
                        ignore_out[canon_ign] = ign_val
                out['ignore'] = ignore_out
            elif canon_sub == 'equivalence':
                out['equivalence'] = raw_val  # pass through
            else:
                out[canon_sub] = raw_val
        return out

    # ─────────────────────────────────────────────────────────────────────────
    # Keywords for parser (preamble / question / answers section headers)
    # ─────────────────────────────────────────────────────────────────────────

    def get_parser_keywords(self) -> Dict[str, List[str]]:
        """
        Return merged keyword lists for parser use.
        Always includes English keywords; adds current-lang keywords if different.
        """
        en_cfg = self.fallback.get('quiz_configs', {})
        lang_cfg = self.translations.get('quiz_configs', {}) if self.language != self.fallback_language else {}

        def merge_lists(key: str) -> List[str]:
            en_list = en_cfg.get(key, [])
            lang_list = lang_cfg.get(key, [])
            combined = list(en_list)
            for item in lang_list:
                if item not in combined:
                    combined.append(item)
            return combined

        return {
            'preamble': merge_lists('preamble_keywords'),
            'question': merge_lists('question_keywords'),
            'answers':  merge_lists('answers_keywords'),
        }

    # ─────────────────────────────────────────────────────────────────────────
    # Quiz type helpers
    # ─────────────────────────────────────────────────────────────────────────

    def get_quiz_type(self, synonym: str) -> Optional[str]:
        return self.quiz_type_map.get(synonym.lower())

    def get_quiz_synonyms(self, quiz_type: str) -> List[str]:
        quiz_types = self.translations.get('quiz_types', self.fallback.get('quiz_types', {}))
        return quiz_types.get(quiz_type, {}).get('synonyms', [])

    def get_all_quiz_type_synonyms(self) -> List[str]:
        return list(self.quiz_type_map.keys())

    # ─────────────────────────────────────────────────────────────────────────
    # Translation helper
    # ─────────────────────────────────────────────────────────────────────────

    def t(self, key: str, fallback: str = '') -> str:
        keys = key.split('.')
        value = self.translations
        for k in keys:
            if isinstance(value, dict):
                value = value.get(k)
            else:
                value = None
                break
        if value is not None:
            return str(value)

        value = self.fallback
        for k in keys:
            if isinstance(value, dict):
                value = value.get(k)
            else:
                value = None
                break
        if value is not None:
            return str(value)

        return fallback or key



