//! mdbook preprocessor that converts `{{note: ...}}` markers into inline
//! hover-triggered popovers.
//!
//! Notes are included like: `Some text{{note: This is a note with **markdown**}} in body.`
//!
//! The `markdown` boolean config value in `book.toml` indicates that Markdown
//! should be emitted for non-HTML backends instead of HTML popovers.
use clap::{Arg, Command};
use log::warn;
use mdbook_preprocessor::{
    book::{Book, BookItem},
    errors::Error,
    Preprocessor, PreprocessorContext,
};
use pulldown_cmark::{html, Options, Parser};
use regex::Regex;
use std::sync::LazyLock;
use std::{io, process};

/// Name of this preprocessor.
const NAME: &str = "inplace-note-preprocessor";

/// Regex to match `{{note: ...}}` markers (dot-matches-newline for multiline content).
static NOTE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?s)\{\{note:\s*(?P<content>.*?)\}\}").unwrap());

/// The CSS injected once per chapter when notes are present.
///
/// All colors use `var()` references to mdBook's CSS custom properties
/// so the popover automatically adapts to the current theme (light, coal,
/// navy, ayu, rust, or any custom theme).
fn popover_css(
    popover_font_size: &str,
    popover_max_width: &str,
    popover_min_width: &str,
    trigger_hit_padding: &str,
    popover_width: &str,
) -> String {
    let css = r#"<style>
.inplace-note-trigger {
  font-size: 0.75em;
  vertical-align: super;
  line-height: 1;
  color: var(--links, #0969da);
  font-weight: 600;
  cursor: help;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding: __TRIGGER_HIT_PADDING__;
}
/* Inline wrapper — surrounds trigger in inline context */
.inplace-note-inline {
  position: relative;
  display: inline-block;
  vertical-align: baseline;
}
/* Invisible hover bridge above trigger (matches popover gap) */
.inplace-note-inline::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  min-width: 1.5em;
  height: 10px;
}
/* Popover for inline-only notes (no block elements) */
.inplace-note-inline .inplace-note-popover-inline {
  display: none;
  /* Horizontal padding kept between the popover and the viewport edge when
     the JS clamp nudges an overflowing popover back inside. Override per
     theme or per element, e.g. `--popover-edge-pad: 20px;`. */
  --popover-edge-pad: 15px;
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  background: var(--theme-popup-bg, var(--bg, #fff));
  color: var(--fg, #000);
  border: 1px solid var(--theme-popup-border, #d0d7de);
  border-radius: 8px;
  padding: 10px 14px;
  min-width: __POPOVER_MIN_WIDTH__;
  max-width: __POPOVER_MAX_WIDTH__;
  __POPOVER_WIDTH_LINE__
  box-shadow: 0 8px 24px rgba(0,0,0,0.15);
  z-index: 1000;
  font-size: __POPOVER_FONT_SIZE__;
  font-weight: normal;
  line-height: 1.5;
  text-align: left;
  white-space: normal;
  cursor: auto;
}
.inplace-note-inline .inplace-note-popover-inline::after {
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow-left, 50%);
  transform: translateX(-50%);
  border: 7px solid transparent;
  border-top-color: var(--theme-popup-bg, var(--bg, #fff));
}
.inplace-note-inline .inplace-note-popover-inline::before {
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow-left, 50%);
  transform: translateX(-50%);
  border: 8px solid transparent;
  border-top-color: var(--theme-popup-border, #d0d7de);
}
.inplace-note-inline:hover .inplace-note-popover-inline {
  display: block;
}
/* Block wrapper — used when note content has block elements */
.inplace-note-block {
  position: relative;
  display: inline-block;
  vertical-align: top;
}
.inplace-note-block .inplace-note-popover-block {
  display: none;
  /* See .inplace-note-popover-inline for --popover-edge-pad docs. */
  --popover-edge-pad: 15px;
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  background: var(--theme-popup-bg, var(--bg, #fff));
  color: var(--fg, #000);
  border: 1px solid var(--theme-popup-border, #d0d7de);
  border-radius: 8px;
  padding: 12px 16px;
  min-width: __POPOVER_MIN_WIDTH__;
  max-width: __POPOVER_MAX_WIDTH__;
  __POPOVER_WIDTH_LINE__
  box-shadow: 0 8px 24px rgba(0,0,0,0.15);
  z-index: 1000;
  font-size: __POPOVER_FONT_SIZE__;
  font-weight: normal;
  line-height: 1.5;
  text-align: left;
  white-space: normal;
  cursor: auto;
}
.inplace-note-block .inplace-note-popover-block::after {
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow-left, 50%);
  transform: translateX(-50%);
  border: 7px solid transparent;
  border-top-color: var(--theme-popup-bg, var(--bg, #fff));
}
.inplace-note-block .inplace-note-popover-block::before {
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow-left, 50%);
  transform: translateX(-50%);
  border: 8px solid transparent;
  border-top-color: var(--theme-popup-border, #d0d7de);
}
.inplace-note-block:hover .inplace-note-popover-block {
  display: block;
}
/* Flipped orientation: popover appears below the trigger when there isn't
   room above (e.g. a note in the first viewport-height of the page). The
   JS toggles `.inplace-note-below` on the wrapper based on the trigger's
   position relative to the viewport top. */
.inplace-note-inline.inplace-note-below .inplace-note-popover-inline {
  bottom: auto;
  top: calc(100% + 10px);
}
.inplace-note-inline.inplace-note-below .inplace-note-popover-inline::after {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--theme-popup-bg, var(--bg, #fff));
}
.inplace-note-inline.inplace-note-below .inplace-note-popover-inline::before {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--theme-popup-border, #d0d7de);
}
.inplace-note-block.inplace-note-below .inplace-note-popover-block {
  bottom: auto;
  top: calc(100% + 10px);
}
.inplace-note-block.inplace-note-below .inplace-note-popover-block::after {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--theme-popup-bg, var(--bg, #fff));
}
.inplace-note-block.inplace-note-below .inplace-note-popover-block::before {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--theme-popup-border, #d0d7de);
}
/* Below-hover-bridge for the inline wrapper: keeps the wrapper :hover'ed
   while the mouse crosses the 10px gap below the trigger toward a
   below-positioned popover (mirrors the above-bridge `::after`). Hidden
   in the default (above) state so it doesn't affect layout. */
.inplace-note-inline::before {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  min-width: 1.5em;
  height: 10px;
  display: none;
}
.inplace-note-inline.inplace-note-below::before {
  display: block;
}
/* Popover inner content styling */
.inplace-note-content a {
  color: var(--links, #0969da);
}
.inplace-note-content a:hover {
  color: var(--links, #0969da);
}
.inplace-note-content p {
  margin: 0.25em 0;
}
.inplace-note-content p:first-child {
  margin-top: 0;
}
.inplace-note-content p:last-child {
  margin-bottom: 0;
}
.inplace-note-content pre {
  margin: 0.5em 0;
  padding: 8px 12px;
  font-size: var(--code-font-size, 0.875em);
  background: var(--quote-bg, #f6f8fa);
}
.inplace-note-content code {
  font-size: var(--code-font-size, 0.875em);
  color: var(--inline-code-color, #301900);
}
.inplace-note-content pre code {
  color: var(--fg, #000);
}
.inplace-note-content ul,
.inplace-note-content ol {
  margin: 0.25em 0;
  padding-left: 1.5em;
}
.inplace-note-content blockquote {
  margin: 0.5em 0;
  padding: 0 0.75em;
  border-left: 3px solid var(--theme-popup-border, #d0d7de);
  color: var(--fg, #656d76);
}
.inplace-note-content table {
  margin: 0.5em 0;
  border-collapse: collapse;
  font-size: 0.85em;
}
.inplace-note-content th,
.inplace-note-content td {
  padding: 4px 8px;
  border: 1px solid var(--table-border-color, #d0d7de);
}
.inplace-note-content th {
  background: var(--table-header-bg, #f6f8fa);
}
.inplace-note-content td {
  background: var(--theme-popup-bg, var(--bg, #fff));
}
</style>"#;
    let width_line = if popover_width.is_empty() {
        String::new()
    } else {
        format!("  width: {};", popover_width)
    };

    css.replace("__POPOVER_FONT_SIZE__", popover_font_size)
        .replace("__POPOVER_MAX_WIDTH__", popover_max_width)
        .replace("__POPOVER_MIN_WIDTH__", popover_min_width)
        .replace("__POPOVER_WIDTH_LINE__", &width_line)
        .replace("__TRIGGER_HIT_PADDING__", trigger_hit_padding)
}

/// Inline `<script>` injected once per chapter (HTML mode) that keeps popovers
/// inside the horizontal viewport on narrow screens.
///
/// The CSS positions the popover with `left: 50%; transform: translateX(-50%)`,
/// which centers it on the trigger. On narrow viewports the popover can then
/// stick out past either edge of the viewport. This script:
///   - On `mouseenter` of a wrapper, measures the now-visible popover with
///     `getBoundingClientRect()`, computes how far it overflows the viewport
///     on each side, and applies `translateX(calc(-50% + Spx))` to nudge it
///     back inside (clamping only the overflowing side).
///   - Repositions the popover's `::after` / `::before` arrow so it still
///     points at the trigger, by writing `--arrow-left` on the wrapper. The
///     arrow's `left: var(--arrow-left, 50%)` then resolves to the correct
///     percentage of the popover's width.
///   - Re-clamps on `scroll` and `resize` (rAF-coalesced) so the position
///     stays correct while a popover is open and the page moves beneath it.
///   - Uses a per-element init marker so multi-chapter pages (e.g. mdBook's
///     `print.html`) don't double-bind the same wrapper.
fn popover_js() -> String {
    r#"<script>
(function(){
  function popoverFor(wrap){
    return wrap.querySelector('.inplace-note-popover-inline,.inplace-note-popover-block');
  }
  function readEdgePad(pop){
    // Read --popover-edge-pad from the popover's computed style. Falls back
    // to 15 if unset or unparseable. Only px and unitless values are
    // supported — rem/em would require resolving against the popover's
    // font-size, which adds complexity for little benefit.
    var raw = getComputedStyle(pop).getPropertyValue('--popover-edge-pad').trim();
    if (!raw) return 15;
    var match = raw.match(/^(-?\d*\.?\d+)\s*(px)?$/);
    if (!match) return 15;
    var n = parseFloat(match[1]);
    return n < 0 ? 0 : n;
  }
  function readTopPad(pop){
    // Resolve the vertical pad used to decide whether the popover should
    // flip below the trigger. The popover, positioned above, overlaps any
    // sticky/fixed element at the top of the viewport (typically mdBook's
    // menu bar) even when it's not cut off by the viewport edge, so the
    // threshold must account for those elements. Order of precedence:
    //   1. The --popover-top-pad custom property on the popover (px or
    //      unitless). Lets users override per theme or per element.
    //   2. The bottom edge of #mdbook-menu-bar if it's present and still
    //      on-screen. Catches the common case out of the box.
    //   3. 0: flip only when the popover would actually be cut off.
    var raw = getComputedStyle(pop).getPropertyValue('--popover-top-pad').trim();
    if (raw) {
      var match = raw.match(/^(-?\d*\.?\d+)\s*(px)?$/);
      if (match) {
        var n = parseFloat(match[1]);
        if (n >= 0) return n;
      }
    }
    var menuBar = document.getElementById('mdbook-menu-bar');
    if (menuBar) {
      var rect = menuBar.getBoundingClientRect();
      if (rect.bottom > 0) return rect.bottom;
    }
    return 0;
  }
  function clamp(wrap){
    var pop = popoverFor(wrap);
    if (!pop || pop.offsetWidth === 0) {
      wrap.style.removeProperty('--arrow-left');
      wrap.classList.remove('inplace-note-below');
      return;
    }
    // The edge pad is static for the life of the page, so cache it on the
    // wrapper to avoid calling getComputedStyle on every scroll/resize
    // frame.
    var PAD = wrap.__inplaceNotePad;
    if (PAD == null) {
      PAD = readEdgePad(pop);
      wrap.__inplaceNotePad = PAD;
    }
    // The top pad depends on the menu bar height, which can change on
    // resize, so we cache it too but invalidate the cache in
    // onViewportChange.
    var topPad = wrap.__inplaceNoteTopPad;
    if (topPad == null) {
      topPad = readTopPad(pop);
      wrap.__inplaceNoteTopPad = topPad;
    }
    // Orientation: if the popover, positioned above, would be cut off by
    // the viewport top OR overlap with a sticky element at the top
    // (typically the mdBook menu bar), flip it below the trigger via
    // .inplace-note-below. The browser re-lays-out on the next frame, so
    // we schedule a follow-up clamp to apply the horizontal shift in the
    // new orientation.
    var trigger = wrap.querySelector('.inplace-note-trigger');
    if (trigger) {
      var tr = trigger.getBoundingClientRect();
      var aboveTop = tr.top - 10 - pop.offsetHeight;
      var wantBelow = aboveTop < topPad;
      var isBelow = wrap.classList.contains('inplace-note-below');
      if (wantBelow !== isBelow) {
        if (wantBelow) wrap.classList.add('inplace-note-below');
        else wrap.classList.remove('inplace-note-below');
        // Clear any inline shift from the previous orientation; the
        // re-queued clamp will recompute it.
        pop.style.transform = '';
        wrap.style.removeProperty('--arrow-left');
        scheduleClamp(wrap);
        return;
      }
    }
    var r = pop.getBoundingClientRect();
    var vw = window.innerWidth || document.documentElement.clientWidth;
    // Positive when the popover overflows that side beyond PAD.
    var overflowR = r.right - vw + PAD;
    var overflowL = PAD - r.left;
    var shift = 0;
    if (overflowR > 0 && overflowL > 0) {
      // Popover wider than the viewport: leave it centered. The wrapper
      // (and therefore the arrow) stays under the trigger.
      shift = 0;
    } else if (overflowR > 0) {
      shift = -overflowR;
    } else if (overflowL > 0) {
      shift = overflowL;
    }
    if (shift === 0) {
      pop.style.transform = '';
      wrap.style.removeProperty('--arrow-left');
      return;
    }
    pop.style.transform = 'translateX(calc(-50% + ' + shift + 'px))';
    // Wrapper center, in popover-local x, is at popW/2 - shift (positive shift
    // moves the popover right, so the wrapper sits further left within it).
    var popW = pop.offsetWidth;
    var arrowPct = ((popW / 2 - shift) / popW) * 100;
    if (arrowPct < 0) arrowPct = 0;
    else if (arrowPct > 100) arrowPct = 100;
    wrap.style.setProperty('--arrow-left', arrowPct + '%');
  }
  function reset(wrap){
    var pop = popoverFor(wrap);
    if (pop) pop.style.transform = '';
    wrap.style.removeProperty('--arrow-left');
    // Drop the flipped-orientation class so the next hover starts above.
    wrap.classList.remove('inplace-note-below');
  }
  function scheduleClamp(wrap){
    if (wrap.__inplaceNoteRaf) return;
    wrap.__inplaceNoteRaf = requestAnimationFrame(function(){
      wrap.__inplaceNoteRaf = 0;
      clamp(wrap);
    });
  }
  var openWrappers = [];
  function setup(){
    var els = document.querySelectorAll('.inplace-note-inline,.inplace-note-block');
    for (var i = 0; i < els.length; i++) {
      var wrap = els[i];
      if (wrap.__inplaceNoteInit) continue;
      wrap.__inplaceNoteInit = true;
      (function(w){
        w.addEventListener('mouseenter', function(){
          if (openWrappers.indexOf(w) === -1) openWrappers.push(w);
          scheduleClamp(w);
        });
        w.addEventListener('mouseleave', function(){
          var idx = openWrappers.indexOf(w);
          if (idx !== -1) openWrappers.splice(idx, 1);
          if (w.__inplaceNoteRaf) {
            cancelAnimationFrame(w.__inplaceNoteRaf);
            w.__inplaceNoteRaf = 0;
          }
          reset(w);
        });
      })(wrap);
    }
  }
  var scrollRafPending = false;
  function onViewportChange(){
    if (scrollRafPending) return;
    scrollRafPending = true;
    requestAnimationFrame(function(){
      scrollRafPending = false;
      // Invalidate cached top pads: the menu bar height can change on
      // resize, and even on scroll the cached value is stale by one frame
      // (cheap to clear).
      for (var i = 0; i < openWrappers.length; i++) {
        openWrappers[i].__inplaceNoteTopPad = null;
        scheduleClamp(openWrappers[i]);
      }
    });
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', setup);
  } else {
    setup();
  }
  window.addEventListener('scroll', onViewportChange, { passive: true });
  window.addEventListener('resize', onViewportChange, { passive: true });
})();
</script>"#.to_string()
}

pub fn make_app() -> Command {
    Command::new("inplace-note-preprocessor")
        .about("An mdbook preprocessor which converts {{note: ...}} markers into hover popovers")
        .subcommand(
            Command::new("supports")
                .arg(Arg::new("renderer").required(true))
                .about("Check whether a renderer is supported by this preprocessor"),
        )
}

fn main() {
    env_logger::init();
    let matches = make_app().get_matches();
    if let Some(sub_args) = matches.subcommand_matches("supports") {
        let renderer = sub_args
            .get_one::<String>("renderer")
            .expect("Required argument");

        if InplaceNote::supports_renderer(renderer) {
            process::exit(0);
        } else {
            process::exit(1);
        }
    } else {
        let (ctx, book) =
            mdbook_preprocessor::parse_input(io::stdin()).expect("Failed to parse input");
        let preprocessor = InplaceNote::new(&ctx);

        let processed_book = preprocessor
            .run(&ctx, book)
            .expect("Failed to process book");
        serde_json::to_writer(io::stdout(), &processed_book)
            .expect("Failed to emit processed book");
    }
}

/// A pre-processor that expands `{{note: ...}}` markers into inline hover popovers.
pub struct InplaceNote {
    /// Whether to emit Markdown instead of HTML (for non-HTML backends).
    md_notes: bool,
    /// Custom popover font-size (e.g. "80%", "1.4rem", "14px"). Defaults to "1.6rem".
    popover_font_size: String,
    /// Custom popover max-width (e.g. "500px", "40vw"). Defaults to "380px".
    popover_max_width: String,
    /// Custom popover min-width (e.g. "300px", "25vw"). Defaults to "200px".
    popover_min_width: String,
    /// Padding around the superscript trigger to enlarge the hover hit area (e.g. "6px", "0.35em").
    trigger_hit_padding: String,
    /// Custom popover width (e.g. "250px", "30vw"). Defaults to empty (not set).
    popover_width: String,
}

impl InplaceNote {
    fn new(ctx: &PreprocessorContext) -> Self {
        if ctx.mdbook_version != mdbook_preprocessor::MDBOOK_VERSION {
            warn!(
                "The {NAME} plugin was built against version {} of mdbook, \
                 but we're being called from version {}",
                mdbook_preprocessor::MDBOOK_VERSION,
                ctx.mdbook_version
            );
        }

        let md_notes = ctx
            .config
            .get::<bool>("preprocessor.inplace-note.markdown")
            .ok()
            .flatten()
            .unwrap_or(false);

        if !md_notes {
            let html_renderers = ["html", "linkcheck"];
            if !html_renderers.contains(&ctx.renderer.as_str()) {
                warn!(
                    "Emitting HTML notes for renderer '{}' which may not be HTML-based",
                    ctx.renderer,
                );
            }
        }

        let popover_font_size = ctx
            .config
            .get::<String>("preprocessor.inplace-note.popover-font-size")
            .ok()
            .flatten()
            .unwrap_or_else(|| "1.6rem".to_string());

        let popover_max_width = ctx
            .config
            .get::<String>("preprocessor.inplace-note.popover-max-width")
            .ok()
            .flatten()
            .unwrap_or_else(|| "380px".to_string());

        let popover_min_width = ctx
            .config
            .get::<String>("preprocessor.inplace-note.popover-min-width")
            .ok()
            .flatten()
            .unwrap_or_else(|| "200px".to_string());

        let trigger_hit_padding = ctx
            .config
            .get::<String>("preprocessor.inplace-note.trigger-hit-padding")
            .ok()
            .flatten()
            .unwrap_or_else(|| "6px".to_string());

        let popover_width = ctx
            .config
            .get::<String>("preprocessor.inplace-note.popover-width")
            .ok()
            .flatten()
            .unwrap_or_default();

        Self {
            md_notes,
            popover_font_size,
            popover_max_width,
            popover_min_width,
            trigger_hit_padding,
            popover_width,
        }
    }

    /// Indicate whether a renderer is supported.  Markdown mode can target any renderer.
    fn supports_renderer(renderer: &str) -> bool {
        renderer != "not-supported"
    }
}

impl Preprocessor for InplaceNote {
    fn name(&self) -> &str {
        NAME
    }

    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
        book.for_each_mut(|item| {
            if let BookItem::Chapter(chap) = item {
                let mut notes: Vec<(String, bool)> = vec![]; // (content, has_blocks)
                chap.content = NOTE_RE
                    .replace_all(&chap.content, |caps: &regex::Captures| {
                        let content = caps.name("content").unwrap().as_str().to_owned();
                        let rendered = render_note_html(&content);
                        let has_blocks = note_has_blocks(&rendered);
                        notes.push((rendered, has_blocks));
                        let idx = notes.len();

                        if self.md_notes {
                            format!("[^{}]", idx)
                        } else if has_blocks {
                            // Block content: emit marker inside a block wrapper div
                            // that closes the current paragraph and starts a new one.
                            format!("\n\n<div class=\"inplace-note-block\"><sup class=\"inplace-note-trigger\">{idx}</sup><div class=\"inplace-note-popover-block\"><div class=\"inplace-note-content\">{content_html}</div></div></div>\n\n", idx = idx, content_html = notes[idx - 1].0)
                        } else {
                            // Inline-only: use the inline span approach.
                            format!(
                                "<span class=\"inplace-note-inline\"><sup class=\"inplace-note-trigger\">{idx}</sup><span class=\"inplace-note-popover-inline\"><span class=\"inplace-note-content\">{content_html}</span></span></span>",
                                idx = idx,
                                content_html = notes[idx - 1].0
                            )
                        }
                    })
                    .to_string();

                // Inject CSS + viewport-clamp JS before chapter content
                // (only if HTML mode and notes exist).
                if !self.md_notes && !notes.is_empty() {
                    chap.content = format!(
                        "{css}\n{js}\n{chap_content}",
                        css = popover_css(
                            &self.popover_font_size,
                            &self.popover_max_width,
                            &self.popover_min_width,
                            &self.trigger_hit_padding,
                            &self.popover_width,
                        ),
                        js = popover_js(),
                        chap_content = chap.content
                    );
                }

                // In markdown mode, append note definitions at the end of the chapter.
                if self.md_notes && !notes.is_empty() {
                    chap.content += "\n---\n";
                    for (idx, (rendered, _)) in notes.into_iter().enumerate() {
                        chap.content += &format!("\n\n[^{}]: {}", idx + 1, rendered);
                    }
                }
            }
        });
        Ok(book)
    }

    fn supports_renderer(&self, renderer: &str) -> Result<bool, Error> {
        Ok(Self::supports_renderer(renderer))
    }
}

/// Render markdown content to HTML for display inside the popover.
///
/// If the content is a single paragraph (no block elements inside),
/// the outer `<p>` tags are stripped so the content flows naturally
/// inside the inline popover span.
fn render_note_html(markdown: &str) -> String {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_FOOTNOTES);
    options.insert(Options::ENABLE_TASKLISTS);
    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);

    let parser = Parser::new_ext(markdown, options);
    let mut html = String::new();
    html::push_html(&mut html, parser);

    // Strip wrapping <p>...</p> if the content is a single paragraph
    // with no nested block-level elements.
    let trimmed = html.trim();
    if trimmed.starts_with("<p>") && trimmed.ends_with("</p>") {
        let inner = &trimmed[3..trimmed.len() - 4];
        let block_tags = [
            "<p>", "<ul>", "<ol>", "<pre>", "<blockquote>",
            "<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>",
            "<table>", "<hr",
        ];
        if !block_tags.iter().any(|tag| inner.contains(tag)) {
            return inner.to_string();
        }
    }

    html
}

/// Check whether rendered HTML contains block-level elements.
fn note_has_blocks(html: &str) -> bool {
    let trimmed = html.trim();
    let block_tags = [
        "<p>", "<ul>", "<ol>", "<pre>", "<blockquote>",
        "<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>",
        "<table>", "<hr",
    ];
    // Check if the content has more than one paragraph or any block tag.
    // A single <p> wrapper doesn't count.
    let inner = if trimmed.starts_with("<p>") && trimmed.ends_with("</p>") {
        &trimmed[3..trimmed.len() - 4]
    } else {
        trimmed
    };
    block_tags.iter().any(|tag| inner.contains(tag))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_regex_simple_note() {
        let input = "Hello{{note: a tip}} world";
        let mut notes = vec![];
        let result = NOTE_RE
            .replace_all(input, |caps: &regex::Captures| {
                let content = caps.name("content").unwrap().as_str().to_owned();
                notes.push(content);
                format!("[{}]", notes.len())
            })
            .to_string();
        assert_eq!(result, "Hello[1] world");
        assert_eq!(notes, vec!["a tip"]);
    }

    #[test]
    fn test_regex_multiline_note() {
        let input = "Text{{note: line1\nline2}} end";
        let mut notes = vec![];
        NOTE_RE.replace_all(input, |caps: &regex::Captures| {
            notes.push(caps.name("content").unwrap().as_str().to_owned());
            ""
        });
        assert_eq!(notes, vec!["line1\nline2"]);
    }

    #[test]
    fn test_regex_multiple_notes() {
        let input = "A{{note: first}} B{{note: second}} C";
        let mut notes = vec![];
        let result = NOTE_RE
            .replace_all(input, |caps: &regex::Captures| {
                let content = caps.name("content").unwrap().as_str().to_owned();
                notes.push(content);
                format!("[{}]", notes.len())
            })
            .to_string();
        assert_eq!(result, "A[1] B[2] C");
        assert_eq!(notes, vec!["first", "second"]);
    }

    #[test]
    fn test_render_note_html_inline() {
        let html = render_note_html("just some text");
        // Should NOT have <p> wrapper
        assert!(!html.contains("<p>"), "unexpected <p>: {html}");
        assert_eq!(html.trim(), "just some text");
    }

    #[test]
    fn test_render_note_html_with_formatting() {
        let html = render_note_html("**bold** and *italic*");
        assert!(html.contains("<strong>bold</strong>"));
        assert!(html.contains("<em>italic</em>"));
        assert!(!html.contains("<p>"), "unexpected <p>: {html}");
    }

    #[test]
    fn test_render_note_html_with_code() {
        let html = render_note_html("use `foo()` here");
        assert!(html.contains("<code>foo()</code>"));
    }

    #[test]
    fn test_render_note_html_with_block_elements() {
        // Content with a list should keep <p> tags
        let html = render_note_html("para\n\n- item 1\n- item 2");
        assert!(html.contains("<ul>"), "expected <ul> in: {html}");
        assert!(html.contains("<li>"), "expected <li> in: {html}");
    }

    #[test]
    fn test_render_note_html_empty() {
        let html = render_note_html("");
        assert!(html.is_empty() || html == "<p>\n</p>\n");
    }

    #[test]
    fn test_note_has_blocks_inline() {
        let html = render_note_html("just text");
        assert!(!note_has_blocks(&html));
    }

    #[test]
    fn test_note_has_blocks_with_list() {
        let html = render_note_html("text\n\n- item");
        assert!(note_has_blocks(&html));
    }

    #[test]
    fn test_note_has_blocks_with_codeblock() {
        let html = render_note_html("```rust\nlet x = 1;\n```");
        assert!(note_has_blocks(&html));
    }

    #[test]
    fn test_note_has_blocks_with_table() {
        let html = render_note_html("| a | b |\n|---|---|\n| 1 | 2 |");
        assert!(note_has_blocks(&html));
    }

    #[test]
    fn test_popover_css_includes_font_size() {
        let css = popover_css("14px", "500px", "300px", "8px", "250px");
        assert!(css.contains("font-size: 14px;"), "font-size not injected: {css}");
        assert!(css.contains("max-width: 500px;"), "max-width not injected: {css}");
        assert!(css.contains("min-width: 300px;"), "min-width not injected: {css}");
        assert!(css.contains("width: 250px;"), "width not injected: {css}");
        assert!(css.contains("padding: 8px;"), "trigger hit padding not injected: {css}");
        // Must be scoped to popover selectors, not leak to page-level elements.
        assert!(!css.contains("body"), "CSS should not style <body>");
        assert!(!css.contains(".content {"), "CSS should not style .content");
    }

    #[test]
    fn test_popover_css_defaults() {
        let css = popover_css("1.6rem", "380px", "200px", "6px", "");
        assert!(css.contains("font-size: 1.6rem;"));
        assert!(css.contains("max-width: 380px;"));
        assert!(css.contains("min-width: 200px;"));
        // The width option is empty, so no standalone `width:` line (other than min-width/max-width
        // and the built-in width: 100% on ::after) should be present.
        // We verify indirectly by asserting the expected defaults are still in place.
        // No standalone `width` property from the option is emitted.
        // (The CSS already has `width: 100%` in ::after, which is unrelated.)
        assert!(css.contains("padding: 6px;"));
    }

    #[test]
    fn test_popover_css_arrow_uses_custom_property() {
        // All four arrow rules (inline + block, ::after + ::before) must read
        // their `left` from `--arrow-left` with a 50% fallback so the static
        // (JS-disabled) layout is unchanged.
        let css = popover_css("1.6rem", "380px", "200px", "6px", "");
        let count = css.matches("left: var(--arrow-left, 50%);").count();
        assert_eq!(
            count, 4,
            "expected 4 arrow rules to use var(--arrow-left, 50%), found {count} in:\n{css}"
        );
        // Sanity: no arrow should still pin to 50% hard-coded.
        assert!(
            !css.contains("border-top-color: var(--theme-popup-bg, var(--bg, #fff));\n  left: 50%;"),
            "arrow still hard-codes left: 50%:\n{css}"
        );
        // The hover-bridge ::after on .inplace-note-inline (NOT a popover arrow)
        // should keep its existing `left: 50%;` — we only re-target popover arrows.
        // It appears as `bottom: 100%;\n  left: 50%;`.
        assert!(
            css.contains("bottom: 100%;\n  left: 50%;"),
            "hover-bridge ::after should still center at 50%:\n{css}"
        );
    }

    #[test]
    fn test_popover_js_returns_script() {
        let js = popover_js();
        assert!(js.starts_with("<script>"), "missing <script> tag: {js}");
        assert!(js.trim_end().ends_with("</script>"), "missing </script>: {js}");
        // Must be an IIFE so it doesn't leak globals / re-bind on multi-chapter pages.
        assert!(js.contains("(function()"), "expected IIFE: {js}");
        // Must hook the right events.
        assert!(js.contains("mouseenter"), "missing mouseenter: {js}");
        assert!(js.contains("mouseleave"), "missing mouseleave: {js}");
        assert!(js.contains("'scroll'"), "missing scroll listener: {js}");
        assert!(js.contains("'resize'"), "missing resize listener: {js}");
        // Must drive the --arrow-left custom property that the CSS reads.
        assert!(
            js.contains("--arrow-left"),
            "script must write --arrow-left to drive the arrow"
        );
        // Must use the popover's own width for the arrow percentage, not the
        // trigger's (the previous failed attempt divided by wrap.offsetWidth).
        assert!(
            js.contains("pop.offsetWidth"),
            "arrow position must be computed against popover width"
        );
        // Must guard against double-initialization on multi-chapter pages.
        assert!(
            js.contains("__inplaceNoteInit"),
            "missing idempotency guard for multi-chapter pages"
        );
        // Must schedule the clamp via rAF so the popover is laid out before we
        // measure it (mouseenter fires before the next frame, before the
        // display:block change has been flushed).
        assert!(
            js.contains("requestAnimationFrame"),
            "missing requestAnimationFrame scheduling"
        );
    }

    #[test]
    fn test_popover_css_sets_edge_pad_default() {
        // Both popover rules must declare a default for --popover-edge-pad
        // so the JS clamp has a sensible value out of the box and the
        // override surface is visible in the injected CSS.
        let css = popover_css("1.6rem", "380px", "200px", "6px", "");
        let count = css.matches("--popover-edge-pad: 15px;").count();
        assert_eq!(
            count, 2,
            "expected 2 popover rules to set --popover-edge-pad: 15px;, found {count} in:\n{css}"
        );
    }

    #[test]
    fn test_popover_js_uses_edge_pad_custom_property() {
        // The clamp must read --popover-edge-pad from the popover's computed
        // style and fall back to 15 if unset, so users can tune the edge gap
        // from their theme CSS without touching the preprocessor. The arrow
        // position is derived from the resulting shift, so it moves with it
        // automatically.
        let js = popover_js();
        assert!(
            js.contains("readEdgePad"),
            "missing readEdgePad function: {js}"
        );
        assert!(
            js.contains("--popover-edge-pad"),
            "script must read --popover-edge-pad from the popover: {js}"
        );
        assert!(
            js.contains("getComputedStyle"),
            "must use getComputedStyle to resolve the custom property: {js}"
        );
        // Fallback constant matches the CSS default.
        let fallback_count = js.matches("return 15").count();
        assert!(
            fallback_count >= 2,
            "expected at least 2 `return 15` fallbacks (unset + unparseable), found {fallback_count} in:\n{js}"
        );
        // Cached on the wrapper so we don't hit getComputedStyle per frame.
        assert!(
            js.contains("__inplaceNotePad"),
            "PAD must be cached on the wrapper to avoid per-frame getComputedStyle: {js}"
        );
    }

    #[test]
    fn test_popover_css_supports_below_orientation() {
        // When a note sits near the top of the page, positioning the popover
        // above the trigger pushes it off-screen. The preprocessor must
        // declare a flipped state (`.inplace-note-below` on the wrapper)
        // that moves the popover below the trigger and points its arrow up.
        let css = popover_css("1.6rem", "380px", "200px", "6px", "");

        // Flipped popover position: `top: calc(100% + 10px)` (below the
        // trigger) instead of the default `bottom: calc(100% + 10px)`.
        // One rule per popover type (inline + block).
        let below_popover = css.matches("top: calc(100% + 10px);").count();
        assert_eq!(
            below_popover, 2,
            "expected 2 `.inplace-note-below` popover position rules, found {below_popover} in:\n{css}"
        );
        // Flipped arrows: `bottom: 100%` (above the popover, pointing up at
        // the trigger) with `border-bottom-color` set. The border-top-color
        // must be cleared. One ::after and one ::before per popover type.
        let up_arrows = css.matches("border-bottom-color:").count();
        assert!(
            up_arrows >= 4,
            "expected at least 4 `border-bottom-color:` declarations in flipped arrows, found {up_arrows} in:\n{css}"
        );
        // Sanity: flipped arrows clear border-top-color.
        assert!(
            css.contains("border-top-color: transparent;"),
            "flipped arrows must clear border-top-color:\n{css}"
        );
        // Below-hover-bridge: the inline wrapper gains a `::before` (its
        // existing `::after` is the above-bridge) that's only shown when
        // flipped, so the wrapper stays :hover'ed across the 10px gap
        // between trigger and below-positioned popover.
        assert!(
            css.contains(".inplace-note-inline::before"),
            "missing below-hover-bridge ::before on the inline wrapper:\n{css}"
        );
        assert!(
            css.contains(".inplace-note-inline.inplace-note-below::before"),
            "below-hover-bridge ::before must be gated on .inplace-note-below:\n{css}"
        );
    }

    #[test]
    fn test_popover_js_flips_below_when_above_overflows() {
        // The clamp must detect when the above-positioned popover would
        // overflow the viewport top (e.g. a note in the first viewport-
        // height of the page) and toggle `.inplace-note-below` on the
        // wrapper so the CSS flips it below. It must also re-queue itself
        // so the horizontal shift is applied after the browser re-lays
        // out, and remove the class on reset.
        let js = popover_js();

        // Class is toggled.
        assert!(
            js.contains("inplace-note-below"),
            "script must toggle .inplace-note-below on the wrapper: {js}"
        );
        assert!(
            js.contains("classList.add('inplace-note-below')"),
            "script must add the class to flip below: {js}"
        );
        assert!(
            js.contains("classList.remove('inplace-note-below')"),
            "script must remove the class to flip back (or on reset): {js}"
        );

        // Decision is based on the trigger's viewport position vs. the
        // resolved top pad (NOT the horizontal edge pad, which is for
        // left/right overflow).
        assert!(
            js.contains("aboveTop"),
            "script must compute the would-be-above popover top: {js}"
        );
        assert!(
            js.contains("aboveTop < topPad"),
            "script must flip when above would overflow topPad: {js}"
        );
        // Must NOT compare against PAD (the horizontal edge pad), which is
        // a separate concern.
        assert!(
            !js.contains("aboveTop < PAD"),
            "orientation decision must use topPad, not the horizontal PAD:\n{js}"
        );

        // Re-queues itself after a flip so the horizontal clamp runs in the
        // new orientation.
        let requeue_in_clamp = js
            .matches("scheduleClamp(wrap)")
            .count();
        assert!(
            requeue_in_clamp >= 2,
            "script must re-queue clamp after a flip (found {requeue_in_clamp} scheduleClamp(wrap) calls in clamp, expected >= 2):\n{js}"
        );

        // The class is removed on reset so the next hover starts above.
        // Check that the substring after `function reset(wrap)` contains
        // the class removal — keeps the assertion scoped to reset() rather
        // than the whole script (clamp() also removes the class).
        let reset_idx = js
            .find("function reset(wrap)")
            .expect("function reset(wrap) must exist in the script");
        let after_reset = &js[reset_idx..];
        assert!(
            after_reset.contains("classList.remove('inplace-note-below')"),
            "reset() must remove .inplace-note-below so the next hover starts above: {after_reset}"
        );
    }

    #[test]
    fn test_popover_js_resolves_top_pad_from_menu_bar() {
        // On small viewports the popover, positioned above, can overlap
        // mdBook's sticky menu bar even when it's not actually cut off by
        // the viewport edge. The flip threshold must therefore account for
        // any sticky/fixed element at the top of the viewport. The script
        // resolves the top pad from a custom property, falling back to
        // the bottom edge of #mdbook-menu-bar, and finally to 0.
        let js = popover_js();

        // The helper exists and is called from clamp().
        assert!(
            js.contains("function readTopPad(pop)"),
            "missing readTopPad helper: {js}"
        );
        assert!(
            js.contains("readTopPad(pop)"),
            "clamp must call readTopPad to resolve the top pad: {js}"
        );

        // 1. Custom-property override.
        assert!(
            js.contains("--popover-top-pad"),
            "readTopPad must read --popover-top-pad from the popover: {js}"
        );
        // 2. mdBook menu-bar fallback.
        assert!(
            js.contains("getElementById('mdbook-menu-bar')"),
            "readTopPad must fall back to #mdbook-menu-bar when the custom property is unset: {js}"
        );
        assert!(
            js.contains("getBoundingClientRect"),
            "readTopPad must read the menu bar's bottom via getBoundingClientRect: {js}"
        );
        // Only use the menu bar while it's still on-screen.
        assert!(
            js.contains("rect.bottom > 0"),
            "readTopPad must only use the menu bar while it's on-screen (rect.bottom > 0): {js}"
        );
        // 3. Final fallback to 0.
        let final_fallback = js.matches("return 0;").count();
        assert!(
            final_fallback >= 1,
            "readTopPad must fall back to 0 when neither the custom property nor the menu bar is available (found {final_fallback}):\n{js}"
        );

        // The resolved value is cached on the wrapper (like PAD) and the
        // cache is invalidated on scroll/resize so a menu-bar height
        // change on resize is picked up.
        assert!(
            js.contains("__inplaceNoteTopPad"),
            "topPad must be cached on the wrapper to avoid per-frame lookups: {js}"
        );
        // Find the body of onViewportChange and verify it invalidates the
        // cache. The simplest signal: the substring after the function
        // declaration clears __inplaceNoteTopPad on each open wrapper.
        let ov_idx = js
            .find("function onViewportChange()")
            .expect("onViewportChange must exist");
        let ov_body = &js[ov_idx..];
        assert!(
            ov_body.contains("__inplaceNoteTopPad = null"),
            "onViewportChange must invalidate the cached topPad on resize/scroll:\n{ov_body}"
        );
    }
}
