"""
mkdocs-revealjs — plugin.py

Hooks
-----
on_page_markdown  : save raw slides + config, return placeholder
on_page_content   : replace placeholder with .reveal div
on_post_page      : inject <link> + <script> before </body>

Theming pipeline (per scheme: light / dark, per scope: theme / highlight / mermaid)
-------------------------------------------------------------------------------------
Each light/dark entry supports three ordered layers:

  1. starter / default  — base theme  (CDN built-in name or any URL)
  2. hack               — inline <style> CSS overrides on top of starter
  3. custom             — additional CSS file loaded after starter+hack
                          (mermaid also accepts  custom_js  for SVG patching)

All layers are optional and compose in that order.

Shorthand coercion:
  light: solarized  →  { starter: "solarized", hack: {}, custom: None }
  "starter" and "default" keywords are exact aliases.

Combinations:
  starter only        → load starter
  hack only           → apply overrides on top of plugin default
  custom only         → load custom CSS (no starter loaded)
  starter + hack      → starter then hack inline
  starter + custom    → starter then custom file
  all three           → starter → hack inline → custom file

hack selector rules (auto-prefix per scope, '&' = scope prefix):
  scope     auto-prefix
  theme     .reveal
  highlight .reveal .hljs
  mermaid   .reveal .mermaid

  h1                →  .reveal h1 { … }
  "& .slides"       →  .reveal .slides { … }
  "&.embedded"      →  .reveal.embedded { … }

mermaid custom_js convention:
  The JS file must assign:
    window._mkdocsRevealjsMermaidPatchLight = function(deckEl) { … }
    window._mkdocsRevealjsMermaidPatchDark  = function(deckEl) { … }
  The plugin calls the appropriate function after each mermaid.run().

Separators
----------
  Horizontal slide    :  blank line + --- + blank line
  Vertical slide      :  blank line + ---- + blank line
  Speaker notes       :  Note: at start of line
  End of presentation :  blank line + ===== + blank line

Import mode
-----------
  revealjs:
    presentation: import
    path: ./slides.md

Reveal.js options
-----------------
Plugin-only (not forwarded to Reveal.initialize()):
  cdn, js, theme, highlight, mermaid

Compatible plugin API
---------------------
Any MkDocs plugin that transforms admonitions by injecting HTML markers
into their body can declare itself compatible with mkdocs-revealjs by
exposing a class-level attribute:

    class MyPlugin(BasePlugin):
        REVEALJS_MANAGED_ADMONITIONS = [
            re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
        ]

revealjs will detect this attribute at on_config time, collect all
declared patterns, and protect any admonition whose body matches one of
them — passing it through verbatim instead of re-rendering it via
Python-Markdown.

Additionally, any HTML comment of the form <!-- MKDOCS_PLUGIN_XXX_ -->
is protected by the built-in fallback convention, with no configuration
required.

Plugin ordering requirement:
  Compatible plugins MUST be declared BEFORE revealjs in mkdocs.yml so
  their on_page_markdown runs first and rewrites admonitions before
  revealjs stores page._revealjs_slides.
"""

import json
import logging
import os
import re
import yaml
import markdown as _markdown_lib
from mkdocs.plugins import BasePlugin
from mkdocs.config import config_options
from mkdocs.config.base import Config
from mkdocs.structure.files import File

log = logging.getLogger("mkdocs.plugins.revealjs")

CDN_DEFAULT  = "https://cdn.jsdelivr.net/npm/reveal.js@5.1.0"
HLJS_CDN     = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0"
MERMAID_CDN  = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"

# ---------------------------------------------------------------------------
# Custom Mermaid themes — auto-discovered from the themes/ folder
# ---------------------------------------------------------------------------
_THEMES_DIR    = os.path.join(os.path.dirname(__file__), "themes")
_BASE_CSS_PATH = os.path.join(os.path.dirname(__file__), "assets", "revealjs-base.css")

def _discover_mermaid_themes(themes_dir: str) -> dict:
    result = {}
    if not os.path.isdir(themes_dir):
        return result
    prefix = "mermaid-theme-"
    suffix = ".js"
    for fname in os.listdir(themes_dir):
        if fname.startswith(prefix) and fname.endswith(suffix):
            name = fname[len(prefix):-len(suffix)]
            if not name:
                continue
            camel = "".join(part.capitalize() for part in name.split("-"))
            js_var = f"_mkdocsReveal{camel}ThemeVars"
            result[name] = (os.path.join(themes_dir, fname), js_var)
    return result

_BUNDLED_MERMAID_THEMES = _discover_mermaid_themes(_THEMES_DIR)
_MERMAID_BUILTIN_THEMES = {"base", "default", "neutral", "forest", "dark"}

_LIGHT_BUILTIN = "white"
_DARK_BUILTIN  = "black"
_HLJS_LIGHT    = "atom-one-light"
_HLJS_DARK     = "atom-one-dark"
_MM_LIGHT      = "base"
_MM_DARK       = "dark"

_DIV_PLACEHOLDER = "MKDOCS_REVEALJS_DIV_7f3a9b2c"
_DEFAULT_HEIGHT  = 600

_PLUGIN_ONLY = {"cdn", "js", "theme", "highlight", "mermaid"}
_PAGE_ONLY   = _PLUGIN_ONLY | {"presentation", "path"}

_SEP_H    = r"^\n---\n$"
_SEP_V    = r"^\n----\n$"
_SEP_NOTE = r"^Note:"

_END_MARKER_RE = re.compile(r'\n\n={5,}\n\n', re.MULTILINE)

# CSS scope prefix used for hack auto-prefixing
_HACK_SCOPE_PREFIX = {
    "theme":     ".reveal",
    "highlight": ".reveal .hljs",
    "mermaid":   ".reveal .mermaid",
}

# ---------------------------------------------------------------------------
# Compatible plugin API — protected admonition marker detection
# ---------------------------------------------------------------------------
#
# Built-in fallback convention: any HTML comment <!-- MKDOCS_PLUGIN_XXX_ -->
# inside an admonition body is automatically protected without any config.
#
# Plugin-declared patterns: collected at on_config time from any active plugin
# that exposes REVEALJS_MANAGED_ADMONITIONS (list of compiled re.Pattern).
#
# The combined list is stored in _compatible_plugins (instance attribute
# on RevealjsPlugin, built in on_config).

def _collect_compatible_plugins(plugins_map: dict) -> list:
    """
    Scan all active MkDocs plugins for REVEALJS_MANAGED_ADMONITIONS.

    A compatible plugin declares:
        class MyPlugin(BasePlugin):
            REVEALJS_MANAGED_ADMONITIONS = [
                re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
            ]

    This signals two things to revealjs:
      1. In _preprocess_admonitions: don't re-render admonitions whose body
         matches one of these patterns — pass them verbatim so the external
         plugin's markers reach the final HTML.
      2. In on_page_content: after building the reveal <div>, call the
         external plugin's on_page_content on that HTML fragment so it can
         find its markers and inject the fully-rendered widget HTML.

    Returns a list of (plugin_name, plugin_instance, [patterns]) tuples,
    one entry per compatible plugin found.
    """
    result = []
    for name, plugin in plugins_map.items():
        declared = getattr(plugin, "REVEALJS_MANAGED_ADMONITIONS", None)
        if not declared or not isinstance(declared, (list, tuple)):
            continue
        patterns = [p for p in declared if hasattr(p, "search")]
        if patterns:
            result.append((name, plugin, patterns))
            log.debug(
                f"[revealjs] compatible plugin registered: '{name}' "
                f"with {len(patterns)} admonition pattern(s)"
            )
    return result


def _admonition_is_managed_externally(block: str,
                                      compatible_plugins: list) -> bool:
    """
    Return True if the admonition block has already been transformed by
    a compatible external plugin and must be passed through verbatim.
    """
    for _name, _plugin, patterns in compatible_plugins:
        for pattern in patterns:
            if pattern.search(block):
                return True
    return False


# ---------------------------------------------------------------------------
# SchemeDesc — normalised descriptor for one light-or-dark entry
# ---------------------------------------------------------------------------

class SchemeDesc:
    """Normalised, fully-resolved descriptor for a single light or dark entry."""
    __slots__ = ("starter", "hack", "custom", "custom_js")

    def __init__(self, starter=None, hack=None, custom=None, custom_js=None):
        self.starter   = starter
        self.hack      = hack or {}
        self.custom    = custom
        self.custom_js = custom_js

    def __repr__(self):
        return (f"SchemeDesc(starter={self.starter!r}, "
                f"hack_keys={list(self.hack.keys())}, "
                f"custom={self.custom!r}, custom_js={self.custom_js!r})")


def _coerce_scheme(value, allow_custom_js: bool = False) -> SchemeDesc:
    if value is None:
        return SchemeDesc()
    if isinstance(value, str):
        return SchemeDesc(starter=value.strip() or None)
    if not isinstance(value, dict):
        return SchemeDesc()

    starter = value.get("starter") or value.get("default") or None
    if isinstance(starter, str):
        starter = starter.strip() or None

    hack = value.get("hack") or {}
    if not isinstance(hack, dict):
        hack = {}

    custom = value.get("custom") or None
    if isinstance(custom, str):
        custom = custom.strip() or None

    custom_js = None
    if allow_custom_js:
        custom_js = value.get("custom_js") or None
        if isinstance(custom_js, str):
            custom_js = custom_js.strip() or None

    return SchemeDesc(starter=starter, hack=hack, custom=custom, custom_js=custom_js)


# ---------------------------------------------------------------------------
# _AnyVal — MkDocs config option that accepts any YAML value
# ---------------------------------------------------------------------------

class _AnyVal(config_options.BaseConfigOption):
    """Accept any YAML value (str, dict, bool, None) without type coercion."""
    def run_validation(self, value):
        return value


# ---------------------------------------------------------------------------
# Sub-config schemas
# ---------------------------------------------------------------------------

class ThemeConfig(Config):
    light = _AnyVal()
    dark  = _AnyVal()


class HighlightConfig(Config):
    light = _AnyVal()
    dark  = _AnyVal()


class MermaidConfig(Config):
    activate = config_options.Type(bool, default=False)
    light    = _AnyVal()
    dark     = _AnyVal()


# ---------------------------------------------------------------------------
# Main plugin config schema
# ---------------------------------------------------------------------------

O = config_options.Optional
T = config_options.Type


class RevealjsPluginConfig(Config):
    # ── Plugin-only ──────────────────────────────────────────────────────────
    cdn       = O(T(str))
    theme     = config_options.SubConfig(ThemeConfig)
    highlight = config_options.SubConfig(HighlightConfig)
    js        = O(T(str))
    mermaid   = config_options.SubConfig(MermaidConfig)

    # ── Reveal.js options forwarded verbatim to Reveal.initialize() ──────────
    controls             = O(T(bool))
    controlsTutorial     = O(T(bool))
    controlsLayout       = O(T(str))
    controlsBackArrows   = O(T(str))
    progress             = O(T(bool))
    slideNumber          = O(T(bool))
    showSlideNumber      = O(T(str))
    hash                 = O(T(bool))
    history              = O(T(bool))
    keyboard             = O(T(bool))
    overview             = O(T(bool))
    center               = O(T(bool))
    touch                = O(T(bool))
    loop                 = O(T(bool))
    rtl                  = O(T(bool))
    navigationMode       = O(T(str))
    shuffle              = O(T(bool))
    fragments            = O(T(bool))
    fragmentInURL        = O(T(bool))
    help                 = O(T(bool))
    pause                = O(T(bool))
    showNotes            = O(T(bool))
    autoPlayMedia        = O(T(bool))
    preloadIframes       = O(T(bool))
    autoAnimate          = O(T(bool))
    autoAnimateEasing    = O(T(str))
    autoAnimateDuration  = O(T(float))
    autoAnimateUnmatched = O(T(bool))
    autoSlide            = O(T(int))
    autoSlideStoppable   = O(T(bool))
    defaultTiming        = O(T(int))
    mouseWheel           = O(T(bool))
    previewLinks         = O(T(bool))
    postMessage          = O(T(bool))
    postMessageEvents    = O(T(bool))
    transition           = O(T(str))
    transitionSpeed      = O(T(str))
    backgroundTransition = O(T(str))
    hideInactiveCursor   = O(T(bool))
    hideCursorTime       = O(T(int))
    minScale             = O(T(float))
    maxScale             = O(T(float))

    def load_dict(self, data):
        """Coerce  mermaid: true/false  shorthand into nested dict."""
        if "mermaid" in data and isinstance(data["mermaid"], bool):
            data = dict(data)
            data["mermaid"] = {"activate": data["mermaid"]}
        super().load_dict(data)


# ---------------------------------------------------------------------------
# Resolution helpers
# ---------------------------------------------------------------------------

def _safe_get(cfg, key):
    if cfg is None:
        return None
    try:
        return cfg.get(key)
    except Exception:
        return None


def _resolve_scope(global_cfg, page_cfg: dict, scope: str,
                   default_light=None, default_dark=None,
                   allow_custom_js=False):
    g_light = _coerce_scheme(_safe_get(global_cfg, "light"), allow_custom_js)
    g_dark  = _coerce_scheme(_safe_get(global_cfg, "dark"),  allow_custom_js)

    if g_light.starter is None and default_light:
        g_light.starter = default_light
    if g_dark.starter is None and default_dark:
        g_dark.starter = default_dark

    page_scope = page_cfg.get(scope) if isinstance(page_cfg, dict) else None
    if not isinstance(page_scope, dict):
        return g_light, g_dark

    def _merge(raw, base: SchemeDesc) -> SchemeDesc:
        if raw is None:
            return base
        p = _coerce_scheme(raw, allow_custom_js)
        return SchemeDesc(
            starter   = p.starter   if p.starter   is not None else base.starter,
            hack      = p.hack      if p.hack                  else base.hack,
            custom    = p.custom    if p.custom    is not None else base.custom,
            custom_js = p.custom_js if p.custom_js is not None else base.custom_js,
        )

    light = _merge(page_scope.get("light"), g_light)
    dark  = _merge(page_scope.get("dark"),  g_dark)
    return light, dark


def _resolve_mermaid_activate(global_cfg, page_cfg: dict) -> bool:
    g = bool(_safe_get(global_cfg, "activate"))
    pm = page_cfg.get("mermaid") if isinstance(page_cfg, dict) else None
    if isinstance(pm, bool):
        return pm
    if isinstance(pm, dict) and "activate" in pm:
        return bool(pm["activate"])
    return g


# ---------------------------------------------------------------------------
# CSS hack renderer
# ---------------------------------------------------------------------------

def _render_hack_css(hack: dict, scope: str) -> str:
    if not hack:
        return ""
    prefix = _HACK_SCOPE_PREFIX.get(scope, ".reveal")
    rules  = []
    for selector, props in hack.items():
        if not isinstance(props, dict) or not props:
            continue
        if selector.startswith("&"):
            full_sel = prefix + selector[1:]
        else:
            full_sel = f"{prefix} {selector}"
        declarations = "; ".join(f"{p}: {v}" for p, v in props.items())
        rules.append(f"  {full_sel} {{ {declarations} }}")
    return "\n".join(rules)


# ---------------------------------------------------------------------------
# URL helpers
# ---------------------------------------------------------------------------

def _resolve_url(value: str, cdn_pattern: str, cdn_base: str = None) -> str:
    if not value:
        return ""
    if value.startswith("http") or value.startswith("/"):
        return value
    if cdn_base:
        return cdn_pattern.format(cdn=cdn_base, name=value)
    return cdn_pattern.format(name=value)


def _theme_url(cdn: str, starter, builtin: str) -> str:
    s = starter or builtin
    if s.startswith("http") or s.startswith("/"):
        return s
    return f"{cdn}/dist/theme/{s}.css"


def _load_bundled_css(path: str, id_: str = "") -> str:
    if not path or not os.path.isfile(path):
        log.debug(f"[revealjs] bundled CSS not found (skipping): {path!r}")
        return ""
    try:
        with open(path, encoding="utf-8") as fh:
            id_attr = f' id="{id_}"' if id_ else ""
            return f"<style{id_attr}>\n{fh.read()}\n</style>"
    except OSError:
        log.warning(f"[revealjs] could not read bundled CSS: {path!r}")
        return ""


def _load_bundled_script(path: str) -> str:
    if not path or not os.path.isfile(path):
        return ""
    try:
        with open(path, encoding="utf-8") as fh:
            return f"\n<script>\n{fh.read()}\n</script>"
    except OSError:
        log.warning(f"[revealjs] could not read bundled theme script: {path!r}")
        return ""


def _resolve_mermaid_theme_name(starter: str, default: str) -> tuple:
    name = (starter or default).strip()
    if name in _BUNDLED_MERMAID_THEMES:
        js_path, js_var = _BUNDLED_MERMAID_THEMES[name]
        return "base", js_path, js_var
    return name, None, None


def _hljs_url(starter) -> str:
    s = starter or _HLJS_LIGHT
    if s.startswith("http") or s.startswith("/"):
        return s
    return f"{HLJS_CDN}/styles/{s}.min.css"


# ---------------------------------------------------------------------------
# Markdown helpers
# ---------------------------------------------------------------------------

_FENCE_OPEN_RE = re.compile(r'^(`{3,}|~{3,})', re.MULTILINE)


def _fence_aware_split(text: str, separator: str):
    sep_stripped = separator.strip('\n')
    parts   = []
    current = []
    fence_char  = None
    fence_len   = 0

    for line in text.split('\n'):
        if fence_char is None:
            m = _FENCE_OPEN_RE.match(line)
            if m:
                fence_char = m.group(1)[0]
                fence_len  = len(m.group(1))
                current.append(line)
            elif line == sep_stripped:
                parts.append('\n'.join(current))
                current = []
            else:
                current.append(line)
        else:
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
            current.append(line)

    parts.append('\n'.join(current))
    return parts


def _split_at_end_marker(markdown: str):
    if not _END_MARKER_RE.search(markdown):
        return markdown, None

    parts = _fence_aware_split(markdown, "\n=====\n")
    if len(parts) == 1:
        return markdown, None
    slides = parts[0]
    tail   = '\n'.join(parts[1:]).lstrip('\n') if len(parts) > 1 else None
    if tail == '':
        tail = None
    return slides, tail


def _load_import_file(host_src_path: str, rel_path: str):
    host_dir  = os.path.dirname(os.path.abspath(host_src_path))
    abs_path  = os.path.normpath(os.path.join(host_dir, rel_path))
    if not os.path.isfile(abs_path):
        raise FileNotFoundError(
            f"[revealjs] import path not found: {abs_path!r} "
            f"(referenced from {host_src_path!r})"
        )
    with open(abs_path, encoding="utf-8") as fh:
        raw = fh.read()
    fm_re    = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
    fm_match = fm_re.match(raw)
    if not fm_match:
        raise ValueError(
            f"[revealjs] import file has no YAML front-matter: {abs_path!r}"
        )
    try:
        meta = yaml.safe_load(fm_match.group(1)) or {}
    except yaml.YAMLError as exc:
        raise ValueError(f"[revealjs] YAML parse error in {abs_path!r}: {exc}")
    revealjs     = meta.get("revealjs", {})
    if not isinstance(revealjs, dict):
        revealjs = {}
    import_title = meta.get("title") or None
    body         = raw[fm_match.end():]
    return body, revealjs, import_title


# ---------------------------------------------------------------------------
# Auto-animate markdown pre-processor
# ---------------------------------------------------------------------------

_FENCE_RE = re.compile(
    r'^```'
    r'(\w*)'
    r'(?:\s*\{[^}]*data-id=["\']([^"\']+)["\'][^}]*\})?'
    r'(?:\s*\[([^\]]*)\])?'
    r'\s*$',
    re.MULTILINE,
)


def _slide_has_auto_animate(slide_text: str) -> bool:
    return 'data-auto-animate' in slide_text


def _neutralise_separators_in_fences(markdown: str) -> str:
    _SEP_LINE_RE = re.compile(r'^(-{3,}|={5,})$')

    lines      = markdown.split('\n')
    out        = []
    fence_char = None
    fence_len  = 0
    i          = 0

    while i < len(lines):
        line = lines[i]

        if fence_char is None:
            m = _FENCE_OPEN_RE.match(line)
            if m:
                fence_char = m.group(1)[0]
                fence_len  = len(m.group(1))
            out.append(line)
            i += 1
        else:
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
                out.append(line)
                i += 1
            else:
                if (line == ''
                        and i + 2 < len(lines)
                        and _SEP_LINE_RE.match(lines[i + 1])
                        and lines[i + 2] == ''):
                    out.append(lines[i + 1])
                    i += 3
                else:
                    out.append(line)
                    i += 1

    return '\n'.join(out)


def _preprocess_auto_animate(markdown: str) -> str:
    markdown = _neutralise_separators_in_fences(markdown)
    slides = _fence_aware_split(markdown, "\n---\n")

    result = []
    for i, slide in enumerate(slides):
        if _slide_has_auto_animate(slide):
            result.append(_rewrite_fenced_blocks(slide))
        else:
            result.append(slide)
        if i < len(slides) - 1:
            result.append('\n---\n')
    return ''.join(result)


def _rewrite_fenced_blocks(slide: str) -> str:
    lines = slide.split('\n')
    out   = []
    i     = 0
    block_index = 0
    while i < len(lines):
        m = _FENCE_RE.match(lines[i])
        if m:
            lang        = m.group(1) or ''
            data_id_val = m.group(2)
            line_spec   = m.group(3)
            i += 1
            body_lines = []
            while i < len(lines) and lines[i].strip() != '```':
                body_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            if not data_id_val:
                data_id_val = f'revealjs-code-{block_index}'
            block_index += 1
            ln_attr   = (f' data-line-numbers="{line_spec.strip()}"'
                         if line_spec is not None else ' data-line-numbers')
            lang_attr = f' class="language-{lang}"' if lang else ''
            body      = '\n'.join(body_lines)
            out.append(
                f'<pre data-id="{data_id_val}">'
                f'<code data-trim{ln_attr}{lang_attr}>'
                f'{body}'
                f'</code></pre>'
            )
        else:
            out.append(lines[i])
            i += 1
    return '\n'.join(out)


# ---------------------------------------------------------------------------
# Admonition pre-processor
# ---------------------------------------------------------------------------

_ADMON_HEADER_RE  = re.compile(r'^(!{3}|\?{3}\+?)[ \t]+\S+')
_ADMON_FENCE_RE   = re.compile(r'^(`{3,}|~{3,})')
_EMOJI_TOKEN_RE   = re.compile(r':[a-zA-Z0-9_+\-]+:')
_EMOJI_P_WRAP_RE  = re.compile(r'^<p>(.*?)</p>$', re.DOTALL)


def _build_emoji_substitutor(md_extensions: list, md_configs: dict):
    _EMOJI_NAMES = {'pymdownx.emoji', 'material.extensions.emoji', 'materialx.emoji'}

    emoji_ext_name = None
    for ext in (md_extensions or []):
        name = ext if isinstance(ext, str) else getattr(ext, 'extPath', None) or str(ext)
        if any(e in str(name) for e in _EMOJI_NAMES):
            emoji_ext_name = name
            break
    if not emoji_ext_name:
        return None

    ext_configs = {}
    for k, v in (md_configs or {}).items():
        if any(e in k for e in _EMOJI_NAMES):
            ext_configs[k] = v

    try:
        md = _markdown_lib.Markdown(
            extensions=[emoji_ext_name],
            extension_configs=ext_configs,
        )
    except Exception as exc:
        log.debug(f"[revealjs] emoji extension failed to load ({exc}), skipping")
        return None

    _cache: dict = {}

    def _render_token(token: str) -> str:
        if token in _cache:
            return _cache[token]
        md.reset()
        rendered = md.convert(token)
        m = _EMOJI_P_WRAP_RE.match(rendered.strip())
        result = m.group(1) if m else rendered
        _cache[token] = result
        return result

    def subst(text: str) -> str:
        if ':' not in text:
            return text
        return _EMOJI_TOKEN_RE.sub(lambda m: _render_token(m.group(0)), text)

    log.debug(f"[revealjs] emoji substitutor built from {emoji_ext_name!r}")
    return subst


def _extract_admonition_block(lines: list, start: int):
    i = start + 1
    while i < len(lines):
        l = lines[i]
        if l == '' or l.startswith('    ') or l.startswith('\t'):
            i += 1
        else:
            break
    end = i
    while end > start + 1 and lines[end - 1].strip() == '':
        end -= 1
    return end, '\n'.join(lines[start:end])


def _fix_admonition_title_spans(html: str) -> str:
    """
    Repair admonition-title paragraphs broken by Python-Markdown or
    Reveal.js's browser-side markdown parser.

    TWO problems are fixed here:

    Problem 1 — Python-Markdown splits the <p> at blank lines:
        BROKEN:  <p class="admonition-title">Title</p>
                 <p><span class="mkq-lock-indicator">✅</span></p>
                 <p></p>
        Fix: re-merge the span back inside the title <p>.

    Problem 2 — Reveal.js re-parses the <textarea> content as markdown.
        Even after fix 1, a blank line INSIDE the title <p> causes
        Reveal.js's own markdown parser (browser-side) to split the
        paragraph again.  Fix: remove blank lines inside the title <p>.

    Both fixes are applied here, after compatible-plugin delegation in
    on_page_content.
    """
    import re as _re

    # Step 1: repair Python-Markdown breakage (span ejected into separate <p>)
    html = _re.sub(
        r'(<p\b[^>]*class="admonition-title"[^>]*>)(.*?)</p>'
        r'\s*<p>((?:\s*<span\b[\s\S]*?</span>\s*)+)</p>'
        r'(\s*<p>\s*</p>)?',
        lambda m: m.group(1) + m.group(2) + m.group(3) + '</p>',
        html,
        flags=_re.DOTALL | _re.IGNORECASE,
    )

    # Step 2: remove blank lines inside admonition-title <p> so Reveal.js's
    # browser-side markdown parser does not re-split the paragraph.
    def _compact_title(m):
        inner = m.group(2)
        lines = inner.split('\n')
        compacted = '\n'.join(line.rstrip() for line in lines if line.strip())
        return m.group(1) + '\n' + compacted + '\n</p>'

    html = _re.sub(
        r'(<p\b[^>]*class="admonition-title"[^>]*>)([\s\S]*?)</p>',
        _compact_title,
        html,
        flags=_re.IGNORECASE,
    )
    return html



def _preprocess_admonitions(slides_md: str,
                             md_extensions: list,
                             md_configs: dict,
                             compatible_plugins: list = None) -> str:
    """
    Walk through slides_md line by line.

    - Inside fenced code blocks (``` / ~~~): every line is emitted verbatim.
    - Outside fences:
        • If an admonition body matches a pattern from a compatible plugin
          (declared via REVEALJS_MANAGED_ADMONITIONS), the entire block is
          emitted verbatim — the responsible plugin will process it later via
          a direct on_page_content call on the rendered slide HTML fragment.
        • Other admonition headers trigger block extraction + HTML rendering
          via Python-Markdown (admonition extension).
        • :emoji: tokens on every non-fenced line are substituted with their
          rendered HTML inline if pymdownx.emoji is configured.
        • Everything else is emitted verbatim.

    Parameters
    ----------
    slides_md          : raw markdown content of the slides
    md_extensions      : list of MkDocs markdown_extensions
    md_configs         : dict of MkDocs mdx_configs
    compatible_plugins : list of (name, plugin, patterns) tuples collected
                         by _collect_compatible_plugins at on_config
    """
    if compatible_plugins is None:
        compatible_plugins = []

    # ── Build a minimal Markdown instance for admonitions ───────────────────
    extensions = ['admonition']
    for ext in (md_extensions or []):
        name = ext if isinstance(ext, str) else getattr(ext, 'name', None)
        if name and 'detail' in name and name not in extensions:
            extensions.append(name)
    try:
        md = _markdown_lib.Markdown(
            extensions=extensions,
            extension_configs={
                k: v for k, v in (md_configs or {}).items()
                if any(k in str(e) for e in extensions)
            },
        )
    except Exception:
        md = _markdown_lib.Markdown(extensions=['admonition'])

    emoji_subst = _build_emoji_substitutor(md_extensions, md_configs)

    lines      = slides_md.split('\n')
    out        = []
    fence_char = None
    fence_len  = 0
    i          = 0

    while i < len(lines):
        line = lines[i]

        # ── inside a fenced code block: emit verbatim (no emoji sub) ───────
        if fence_char is not None:
            out.append(line)
            stripped = line.strip()
            if (stripped.startswith(fence_char * fence_len)
                    and all(c == fence_char for c in stripped)):
                fence_char = None
                fence_len  = 0
            i += 1
            continue

        # ── fence opening ───────────────────────────────────────────────────
        m_fence = _ADMON_FENCE_RE.match(line)
        if m_fence:
            fence_char = m_fence.group(1)[0]
            fence_len  = len(m_fence.group(1))
            out.append(line)
            i += 1
            continue

        # ── admonition header ───────────────────────────────────────────────
        if _ADMON_HEADER_RE.match(line):
            end, block = _extract_admonition_block(lines, i)

            # ── Compatible plugin: render admonition then delegate ────────────────
            # If a compatible plugin owns this admonition (detected via
            # REVEALJS_MANAGED_ADMONITIONS patterns), render it via standard
            # Python-Markdown so the HTML structure (div.admonition + MQS
            # markers) ends up in the slide HTML. revealjs then calls the
            # plugin's on_page_content directly on the slide div fragment
            # in on_page_content, so it can inject the final widget HTML.
            if _admonition_is_managed_externally(block, compatible_plugins):
                log.debug(
                    f"[revealjs] admonition at line {i} owned by compatible "
                    f"plugin — rendering via standard admonition"
                )
                if emoji_subst:
                    block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
                md.reset()
                html_block = md.convert(block)
                # Repair spans ejected from admonition-title by Python-Markdown
                # when a blank line separates the title text from the span.
                html_block = _fix_admonition_title_spans(html_block)
                out.append('')
                out.append(html_block)
                out.append('')
                i = end
                continue
            # ── end compatible plugin ───────────────────────────────────────────────────────

            # Standard path: render admonition via Python-Markdown
            if emoji_subst:
                block = '\n'.join(emoji_subst(l) for l in block.split('\n'))
            md.reset()
            html = md.convert(block)
            # Blank lines ensure Reveal.js markdown plugin treats it as raw HTML
            out.append('')
            out.append(html)
            out.append('')
            i = end
            continue

        # ── normal line: apply emoji substitution then emit ─────────────────
        out.append(emoji_subst(line) if emoji_subst else line)
        i += 1

    return '\n'.join(out)


# ---------------------------------------------------------------------------
# Custom URL resolver
# ---------------------------------------------------------------------------

def _resolve_custom_url(value: str, page, config) -> str:
    if not value:
        return value
    if value.startswith("http://") or value.startswith("https://") or value.startswith("/"):
        return value
    page_url = (page.url or "").rstrip("/")
    depth    = page_url.count("/") + (1 if page_url else 0)
    prefix   = "../" * depth
    return prefix + value


# ---------------------------------------------------------------------------
# Plugin
# ---------------------------------------------------------------------------

_H1_RE = re.compile(r'^(#[^#][^\n]*)\n?', re.MULTILINE)


class RevealjsPlugin(BasePlugin[RevealjsPluginConfig]):
    """
    MkDocs plugin that embeds Reveal.js presentations in MkDocs pages.

    Compatible plugin API
    ---------------------
    Any MkDocs plugin that transforms admonitions can declare itself
    compatible by adding a class-level attribute:

        class MyPlugin(BasePlugin):
            REVEALJS_MANAGED_ADMONITIONS = [
                re.compile(r'<!--\\s*MY_MARKER_\\d+_', re.IGNORECASE),
            ]

    revealjs collects these patterns at on_config time and uses them in
    _preprocess_admonitions to skip re-rendering those admonitions.

    Additionally, any HTML comment matching <!-- MKDOCS_PLUGIN_XXX_ --> is
    protected automatically by built-in convention, without any declaration.

    Plugin ordering requirement
    ---------------------------
    Compatible plugins MUST appear BEFORE revealjs in mkdocs.yml plugins:
    so their on_page_markdown runs first and rewrites admonitions before
    revealjs stores page._revealjs_slides.
    """

    _cdn_base:               str  = CDN_DEFAULT
    _global_opts:            dict = {}
    _compatible_plugins:     list = []   # collected at on_config

    def on_config(self, config):
        cdn = self.config.get("cdn")
        self._cdn_base    = cdn.rstrip("/") if cdn else CDN_DEFAULT
        self._global_opts = {
            k: v for k, v in self.config.items()
            if k not in _PLUGIN_ONLY and v is not None
        }

        # ── Collect compatible plugins once per build ───────────────────────
        plugins_map = config.get("plugins", {})
        self._compatible_plugins = _collect_compatible_plugins(plugins_map)
        if self._compatible_plugins:
            names = ', '.join(n for n, _, _ in self._compatible_plugins)
            log.info(f"[revealjs] compatible plugins detected: {names}")

        # Cache custom_dir so on_files can register custom CSS/JS files
        theme_cfg   = config.get("theme") or {}
        custom_dir  = getattr(theme_cfg, "custom_dir", None)
        self._custom_dir    = custom_dir or None
        self._mkdocs_config = config
        return config

    # ---------------------------------------------------------------- files

    def on_files(self, files, config):
        if not self._custom_dir or not os.path.isdir(self._custom_dir):
            return files

        custom_paths = []
        for scope in ("theme", "highlight", "mermaid"):
            scope_cfg = self.config.get(scope)
            for side in ("light", "dark"):
                val = _safe_get(scope_cfg, side)
                desc = _coerce_scheme(val, allow_custom_js=(scope == "mermaid"))
                if desc.custom:
                    custom_paths.append(desc.custom)
                if desc.custom_js:
                    custom_paths.append(desc.custom_js)
        js_path = self.config.get("js")
        if js_path:
            custom_paths.append(js_path)

        for path in custom_paths:
            if not path or path.startswith("http") or path.startswith("/"):
                continue
            abs_path = os.path.join(self._custom_dir, path)
            if not os.path.isfile(abs_path):
                abs_path = os.path.join(os.path.dirname(
                    config.get("config_file_path", "")), path)
                if not os.path.isfile(abs_path):
                    continue
            f = File(
                path       = path,
                src_dir    = self._custom_dir,
                dest_dir   = config["site_dir"],
                use_directory_urls = config.get("use_directory_urls", True),
            )
            if not files.get_file_from_path(path):
                files.append(f)
                log.debug(f"[revealjs] registered custom file: {path!r}")

        return files

    # ---------------------------------------------------------------- markdown

    def on_page_markdown(self, markdown, page, config, files):
        meta     = page.meta or {}
        revealjs = meta.get("revealjs", {})
        if not isinstance(revealjs, dict):
            return markdown
        presentation = revealjs.get("presentation", False)

        # ── import mode ──────────────────────────────────────────────────────
        if presentation == "import":
            rel_path = revealjs.get("path")
            if not rel_path:
                log.error(f"[revealjs] 'presentation: import' requires 'path:' "
                          f"in {page.file.src_path}")
                return markdown
            try:
                import_body, import_cfg, import_title = _load_import_file(
                    page.file.abs_src_path, rel_path)
            except (FileNotFoundError, ValueError) as exc:
                log.error(str(exc))
                return markdown

            host_tail  = markdown.strip() or None
            host_title = meta.get("title")
            h1_line    = None
            tail_body  = host_tail

            if host_tail:
                m = _H1_RE.search(host_tail)
                if m:
                    h1_line   = m.group(1).strip()
                    tail_body = (host_tail[:m.start()] + host_tail[m.end():]).strip() or None
                elif not host_title and import_title:
                    h1_line   = f"# {import_title}"
            elif not host_title and import_title:
                h1_line   = f"# {import_title}"
                tail_body = None

            page._revealjs_slides = _preprocess_auto_animate(import_body.strip())
            page._revealjs_config = import_cfg
            page._revealjs_tail   = tail_body
            return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

        # ── inline mode ──────────────────────────────────────────────────────
        if not presentation:
            return markdown

        slides_part, tail_part = _split_at_end_marker(markdown)
        page._revealjs_slides = _preprocess_auto_animate(slides_part.strip())
        page._revealjs_config = revealjs

        if tail_part is None:
            page._revealjs_tail = None
            return _DIV_PLACEHOLDER

        tail_raw  = tail_part.strip()
        fm_title  = revealjs.get("title") or meta.get("title")
        h1_line   = None
        tail_body = tail_raw or None

        if tail_raw:
            m = _H1_RE.search(tail_raw)
            if m:
                h1_line   = m.group(1).strip()
                tail_body = (tail_raw[:m.start()] + tail_raw[m.end():]).strip() or None
            elif fm_title:
                h1_line   = f"# {fm_title}"
        elif fm_title:
            h1_line   = f"# {fm_title}"
            tail_body = None

        page._revealjs_tail = tail_body
        return "\n\n".join(filter(None, [h1_line, _DIV_PLACEHOLDER, tail_body]))

    # ---------------------------------------------------------------- content

    def on_page_content(self, html, page, config, files):
        if not hasattr(page, "_revealjs_slides"):
            return html
        placeholder_p   = f"<p>{_DIV_PLACEHOLDER}</p>"
        placeholder_raw = _DIV_PLACEHOLDER
        if placeholder_p not in html and placeholder_raw not in html:
            log.error(f"[revealjs] placeholder not found in {page.file.src_path}")
            return html
        revealjs = page._revealjs_config
        height   = revealjs.get("height", self._global_opts.get("height", _DEFAULT_HEIGHT))

        md_extensions = config.get("markdown_extensions") or []
        md_configs    = config.get("mdx_configs") or {}

        # _preprocess_admonitions renders compatible-plugin admonitions via
        # standard Python-Markdown (so markers appear in HTML), then the
        # delegation loop below calls each plugin's on_page_content.
        slides = _preprocess_admonitions(
            page._revealjs_slides,
            md_extensions,
            md_configs,
            compatible_plugins=self._compatible_plugins,
        )

        div_html = self._render_div(slides, height)

        # ── Delegate to compatible plugins ───────────────────────────────────
        # Each compatible plugin's on_page_content is called on the slide div
        # HTML fragment. The plugin finds its markers inside the <textarea>,
        # extracts them, renders its widget, and returns the modified fragment.
        # We pass a synthetic html argument that contains ONLY the div_html so
        # the plugin operates exclusively on the slides content and not on the
        # rest of the page (which has not yet been assembled at this point).
        for _name, ext_plugin, _patterns in self._compatible_plugins:
            if not hasattr(ext_plugin, "on_page_content"):
                continue
            if not hasattr(page, "_mkdocs_quiz_data"):
                # Compatible plugin has no data for this page — skip
                continue
            try:
                result = ext_plugin.on_page_content(div_html, page=page,
                                                     config=config, files=files)
                if result is not None:
                    div_html = result
                    log.debug(f"[revealjs] on_page_content delegated to '{_name}'")
            except Exception as exc:
                log.warning(f"[revealjs] on_page_content delegation to "
                            f"'{_name}' failed: {exc}")
        # ── end delegation ───────────────────────────────────────────────────

        # After delegation, compatible plugins may have produced broken
        # admonition-title paragraphs: Python-Markdown splits the <p> at
        # blank lines, ejecting injected <span>s (e.g. mkq-lock-indicator)
        # into a separate <p>.  Fix that here, after all plugins have run.
        if self._compatible_plugins:
            div_html = _fix_admonition_title_spans(div_html)

        if placeholder_p in html:
            return html.replace(placeholder_p, div_html, 1)
        return html.replace(placeholder_raw, div_html, 1)

    # ---------------------------------------------------------------- post page

    def on_post_page(self, output, page, config):
        if not hasattr(page, "_revealjs_slides"):
            return output
        revealjs = page._revealjs_config
        page_cdn = revealjs.get("cdn")
        cdn      = page_cdn.rstrip("/") if page_cdn else self._cdn_base
        extra_js = revealjs.get("js") or self.config.get("js")

        theme_l, theme_d = _resolve_scope(
            self.config.get("theme"), revealjs, "theme")
        hl_l, hl_d = _resolve_scope(
            self.config.get("highlight"), revealjs, "highlight",
            default_light=_HLJS_LIGHT, default_dark=_HLJS_DARK)
        mm_l, mm_d = _resolve_scope(
            self.config.get("mermaid"), revealjs, "mermaid",
            default_light=_MM_LIGHT, default_dark=_MM_DARK,
            allow_custom_js=True)
        mm_activate = _resolve_mermaid_activate(self.config.get("mermaid"), revealjs)

        page_opts   = {k: v for k, v in revealjs.items() if k not in _PAGE_ONLY}
        merged_opts = {**self._global_opts, **page_opts}
        height      = merged_opts.pop("height", _DEFAULT_HEIGHT)
        js_options  = self._build_js_options(merged_opts)

        def _rc(v): return _resolve_custom_url(v, page, config)
        if theme_l.custom: theme_l.custom = _rc(theme_l.custom)
        if theme_d.custom: theme_d.custom = _rc(theme_d.custom)
        if hl_l.custom:    hl_l.custom    = _rc(hl_l.custom)
        if hl_d.custom:    hl_d.custom    = _rc(hl_d.custom)
        if mm_l.custom:    mm_l.custom    = _rc(mm_l.custom)
        if mm_d.custom:    mm_d.custom    = _rc(mm_d.custom)
        if mm_l.custom_js: mm_l.custom_js = _rc(mm_l.custom_js)
        if mm_d.custom_js: mm_d.custom_js = _rc(mm_d.custom_js)
        if extra_js:       extra_js       = _rc(extra_js)

        assets = self._render_assets(
            cdn=cdn,
            theme_l=theme_l, theme_d=theme_d,
            hl_l=hl_l, hl_d=hl_d,
            mm_l=mm_l, mm_d=mm_d, mm_activate=mm_activate,
            js_opts=js_options, extra_js=extra_js, height=height,
        )
        if "</body>" in output:
            return output.replace("</body>", assets + "\n</body>", 1)
        return output + assets

    # ---------------------------------------------------------------- helpers

    @staticmethod
    def _render_div(slides: str, height: int) -> str:
        return (
            f'<div class="mkdocs-revealjs-wrapper" '
            f'style="width:100%;height:{height}px;margin:1.5rem 0;">\n'
            f'  <div class="reveal" id="mkdocs-revealjs" '
            f'style="width:100%;height:{height}px;">\n'
            f'    <div class="slides">\n'
            f'      <section\n'
            f'        data-markdown\n'
            f'        data-separator="{_SEP_H}"\n'
            f'        data-separator-vertical="{_SEP_V}"\n'
            f'        data-separator-notes="{_SEP_NOTE}"\n'
            f'      >\n'
            f'        <textarea data-template>\n'
            f'{slides}\n'
            f'        </textarea>\n'
            f'      </section>\n'
            f'    </div>\n'
            f'  </div>\n'
            f'</div>'
        )

    @staticmethod
    def _build_js_options(opts: dict) -> str:
        parts = []
        for key, value in opts.items():
            if isinstance(value, bool):
                parts.append(f"      {key}: {'true' if value else 'false'}")
            elif isinstance(value, (int, float)):
                parts.append(f"      {key}: {value}")
            else:
                parts.append(
                    f"      {key}: '{str(value).replace(chr(39), chr(92)+chr(39))}'")
        return ",\n".join(parts)

    def _render_assets(self, cdn,
                       theme_l, theme_d,
                       hl_l, hl_d,
                       mm_l, mm_d, mm_activate,
                       js_opts, extra_js, height) -> str:

        rv_light_url = _theme_url(cdn, theme_l.starter, _LIGHT_BUILTIN)
        rv_dark_url  = _theme_url(cdn, theme_d.starter, _DARK_BUILTIN)
        hl_light_url = _hljs_url(hl_l.starter or _HLJS_LIGHT)
        hl_dark_url  = _hljs_url(hl_d.starter or _HLJS_DARK)

        rv_hack_l = _render_hack_css(theme_l.hack, "theme")
        rv_hack_d = _render_hack_css(theme_d.hack, "theme")
        hl_hack_l = _render_hack_css(hl_l.hack, "highlight")
        hl_hack_d = _render_hack_css(hl_d.hack, "highlight")
        mm_hack_l = _render_hack_css(mm_l.hack, "mermaid") if mm_activate else ""
        mm_hack_d = _render_hack_css(mm_d.hack, "mermaid") if mm_activate else ""

        def link(href, id_, disabled=False):
            d = " disabled" if disabled else ""
            return f'<link rel="stylesheet" href="{href}" id="{id_}"{d}>'

        def opt_link(href, id_, disabled=False):
            return ("\n" + link(href, id_, disabled)) if href else ""

        def style_block(css, id_):
            if not css:
                return ""
            return f'\n<style id="{id_}">\n{css}\n</style>'

        def script(src):
            return f'\n<script src="{src}"></script>' if src else ""

        swap_pairs = [
            ["revealjs-theme-light",    "revealjs-theme-dark"],
            ["revealjs-hljs-light",     "revealjs-hljs-dark"],
            ["revealjs-hack-rv-light",  "revealjs-hack-rv-dark"],
            ["revealjs-hack-hl-light",  "revealjs-hack-hl-dark"],
        ]
        if theme_l.custom or theme_d.custom:
            swap_pairs.append(["revealjs-custom-rv-light", "revealjs-custom-rv-dark"])
        if hl_l.custom or hl_d.custom:
            swap_pairs.append(["revealjs-custom-hl-light", "revealjs-custom-hl-dark"])
        if mm_activate:
            swap_pairs.append(["revealjs-hack-mm-light", "revealjs-hack-mm-dark"])
            if mm_l.custom or mm_d.custom:
                swap_pairs.append(["revealjs-custom-mm-light", "revealjs-custom-mm-dark"])
        swap_pairs_js = json.dumps(swap_pairs)

        base_opts     = (f"      embedded: true,\n"
                         f"      width: '100%',\n"
                         f"      height: {height},\n"
                         f"      margin: 0")
        options_block = base_opts + (",\n" + js_opts if js_opts else "")

        if mm_activate:
            mm_light_theme, mm_light_reg, mm_light_var = _resolve_mermaid_theme_name(mm_l.starter, _MM_LIGHT)
            mm_dark_theme,  mm_dark_reg,  mm_dark_var  = _resolve_mermaid_theme_name(mm_d.starter, _MM_DARK)

            if mm_light_var:
                mm_light_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_light_var}"] || {{}} }}'
            else:
                mm_light_init = f'{{ startOnLoad: false, theme: "{mm_light_theme}" }}'

            if mm_dark_var:
                mm_dark_init = f'{{ startOnLoad: false, theme: "base", themeVariables: window["{mm_dark_var}"] || {{}} }}'
            else:
                mm_dark_init = f'{{ startOnLoad: false, theme: "{mm_dark_theme}" }}'

            mermaid_js = f"""
  // ── Mermaid ─────────────────────────────────────────────────────────────
  function _rvIsDark() {{
    return (document.body.getAttribute("data-md-color-scheme") || "default") === "slate";
  }}
  function _rvMermaidConfig(dark) {{
    return dark ? {mm_dark_init} : {mm_light_init};
  }}
  function _rvStoreMermaidSrc() {{
    document.querySelectorAll(".mermaid:not([data-src])").forEach(function(el) {{
      el.dataset.src = el.textContent;
    }});
  }}
  function _rvCallMermaidPatch(dark) {{
    var fn = dark
      ? window._mkdocsRevealjsMermaidPatchDark
      : window._mkdocsRevealjsMermaidPatchLight;
    if (typeof fn === "function") {{
      var deckEl = document.getElementById("mkdocs-revealjs");
      try {{ fn(deckEl); }} catch(e) {{
        console.warn("[revealjs] mermaid patch error:", e);
      }}
    }}
  }}
  function _rvRenderMermaid(dark) {{
    mermaid.initialize(_rvMermaidConfig(dark));
    document.querySelectorAll(".mermaid[data-processed]").forEach(function(el) {{
      el.removeAttribute("data-processed");
      if (el.dataset.src) {{ el.textContent = el.dataset.src; }}
    }});
    mermaid.run({{ querySelector: ".mermaid" }});
    _rvCallMermaidPatch(dark);
  }}
"""
            mermaid_init = """
    _rvStoreMermaidSrc();
    _rvRenderMermaid(_rvIsDark());
    deck.on("slidechanged", function() { _rvRenderMermaid(_rvIsDark()); });
"""
            mermaid_theme_swap = "    _rvRenderMermaid(isDark);\n"
        else:
            mermaid_js         = ""
            mermaid_init       = ""
            mermaid_theme_swap = ""
            mm_light_reg       = None
            mm_dark_reg        = None
            mm_light_var       = None
            mm_dark_var        = None

        return f"""
<!-- mkdocs-revealjs ───────────────────────────────────────── -->

<!-- Plugin base corrections (always loaded, before any theme) -->
{_load_bundled_css(_BASE_CSS_PATH, "revealjs-base")}

<link rel="stylesheet" href="{cdn}/dist/reveal.css">

<!-- Reveal.js theme: starter (light / dark) -->
{link(rv_light_url, "revealjs-theme-light")}
{link(rv_dark_url,  "revealjs-theme-dark",  disabled=True)}

<!-- Reveal.js theme: hack overrides (light / dark) -->{style_block(rv_hack_l, "revealjs-hack-rv-light")}{style_block(rv_hack_d, "revealjs-hack-rv-dark")}

<!-- Reveal.js theme: custom CSS (light / dark) -->{opt_link(theme_l.custom, "revealjs-custom-rv-light")}{opt_link(theme_d.custom, "revealjs-custom-rv-dark", disabled=True)}

<!-- Highlight.js theme: starter (light / dark) -->
{link(hl_light_url, "revealjs-hljs-light")}
{link(hl_dark_url,  "revealjs-hljs-dark",  disabled=True)}

<!-- Highlight.js theme: hack overrides (light / dark) -->{style_block(hl_hack_l, "revealjs-hack-hl-light")}{style_block(hl_hack_d, "revealjs-hack-hl-dark")}

<!-- Highlight.js theme: custom CSS (light / dark) -->{opt_link(hl_l.custom, "revealjs-custom-hl-light")}{opt_link(hl_d.custom, "revealjs-custom-hl-dark", disabled=True)}

<!-- Mermaid theme: hack overrides (light / dark) -->{style_block(mm_hack_l, "revealjs-hack-mm-light") if mm_activate else ""}{style_block(mm_hack_d, "revealjs-hack-mm-dark") if mm_activate else ""}

<!-- Mermaid theme: custom CSS (light / dark) -->{opt_link(mm_l.custom, "revealjs-custom-mm-light") if mm_activate else ""}{opt_link(mm_d.custom, "revealjs-custom-mm-dark", disabled=True) if mm_activate else ""}

<script src="{cdn}/dist/reveal.js"></script>
<script src="{cdn}/plugin/markdown/markdown.js"></script>
<script src="{cdn}/plugin/highlight/highlight.js"></script>
<script src="{cdn}/plugin/notes/notes.js"></script>
<script src="{cdn}/plugin/math/math.js"></script>
{"<script src=" + repr(MERMAID_CDN) + "></script>" if mm_activate else ""}
{_load_bundled_script(mm_light_reg) if mm_activate and mm_light_reg else ""}
{_load_bundled_script(mm_dark_reg) if mm_activate and mm_dark_reg and mm_dark_reg != mm_light_reg else ""}
{script(mm_l.custom_js) if mm_activate and mm_l.custom_js else ""}
{script(mm_d.custom_js) if mm_activate and mm_d.custom_js and mm_d.custom_js != mm_l.custom_js else ""}
{script(extra_js) if extra_js else ""}
<script>
(function () {{
{mermaid_js}
  // ── Theme switcher ───────────────────────────────────────────────────────
  var _SWAP_PAIRS = {swap_pairs_js};

  function applyTheme(scheme) {{
    var isDark = (scheme === "slate");
    _SWAP_PAIRS.forEach(function(pair) {{
      var light = document.getElementById(pair[0]);
      var dark  = document.getElementById(pair[1]);
      if (light) light.disabled = isDark;
      if (dark)  dark.disabled  = !isDark;
    }});
{mermaid_theme_swap}  }}

  applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");

  new MutationObserver(function(mutations) {{
    mutations.forEach(function(m) {{
      if (m.attributeName === "data-md-color-scheme") {{
        applyTheme(document.body.getAttribute("data-md-color-scheme") || "default");
      }}
    }});
  }}).observe(document.body, {{ attributes: true }});

  // ── Reveal.js init ──────────────────────────────────────────────────────
  function initReveal() {{
    var el = document.getElementById("mkdocs-revealjs");
    if (!el) return;
    var deck = new Reveal(el, {{
{options_block},
      highlight: {{
        highlightOnLoad: true
      }},
      plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealMath]
    }});
    deck.initialize().then(function() {{
        deck.layout();
    {mermaid_init}      document.dispatchEvent(new CustomEvent('mkdocs-revealjs-ready', {{ detail: {{ deck: deck }} }}));
        }});
    window._mkdocsRevealDeck = deck;
  }}

  if (document.readyState === "loading") {{
    document.addEventListener("DOMContentLoaded", initReveal);
  }} else {{
    initReveal();
  }}
}})();
</script>
<!-- ─────────────────────────────────────── mkdocs-revealjs -->"""
