// SiteOne Crawler - Built-in HTTP server for serving exports
// (c) Jan Reges <jan.reges@siteone.cz>
//
// Two modes:
// - Markdown: reads .md files, renders them as styled HTML with table/accordion support
// - Offline: serves static HTML files with Content-Security-Policy restricting to same origin

use std::path::{Path, PathBuf};

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

use crate::utils;
use crate::version;

/// Server mode
pub enum ServeMode {
    Markdown,
    Offline,
}

/// Run the HTTP server for serving exported content.
pub async fn run(root_dir: PathBuf, mode: ServeMode, port: u16, bind_address: &str) {
    if !root_dir.is_dir() {
        eprintln!(
            "{}",
            utils::get_color_text(
                &format!("ERROR: Directory '{}' does not exist.", root_dir.display()),
                "red",
                false,
            )
        );
        std::process::exit(101);
    }

    let mode_name = match mode {
        ServeMode::Markdown => "Markdown",
        ServeMode::Offline => "Offline HTML",
    };

    let addr = format!("{}:{}", bind_address, port);
    let listener = match TcpListener::bind(&addr).await {
        Ok(l) => l,
        Err(e) => {
            eprintln!(
                "{}",
                utils::get_color_text(
                    &format!("ERROR: Cannot bind to {}:{}: {}", bind_address, port, e),
                    "red",
                    false,
                )
            );
            std::process::exit(1);
        }
    };

    let display_host = if bind_address == "0.0.0.0" || bind_address == "127.0.0.1" {
        "localhost"
    } else {
        bind_address
    };

    println!();
    println!(
        "{}",
        utils::get_color_text(
            &format!("SiteOne Crawler v{} - {} Server", version::CODE, mode_name),
            "yellow",
            false,
        )
    );
    println!(
        "  {}",
        utils::get_color_text(&format!("Serving from: {}", root_dir.display()), "gray", false,)
    );
    println!(
        "  {}",
        utils::get_color_text(&format!("URL: http://{}:{}", display_host, port), "cyan", false,)
    );
    if bind_address == "0.0.0.0" {
        println!(
            "  {}",
            utils::get_color_text("Listening on all network interfaces", "yellow", false,)
        );
    }
    println!("  {}", utils::get_color_text("Press Ctrl+C to stop", "gray", false,));
    println!();

    let is_markdown = matches!(mode, ServeMode::Markdown);

    loop {
        match listener.accept().await {
            Ok((stream, _)) => {
                let root = root_dir.clone();
                tokio::spawn(async move {
                    if let Err(e) = handle_connection(stream, &root, is_markdown).await {
                        eprintln!("Connection error: {}", e);
                    }
                });
            }
            Err(e) => eprintln!("Accept error: {}", e),
        }
    }
}

async fn handle_connection(mut stream: TcpStream, root_dir: &Path, is_markdown: bool) -> std::io::Result<()> {
    let mut buf = vec![0u8; 8192];
    let n = match tokio::time::timeout(std::time::Duration::from_secs(30), stream.read(&mut buf)).await {
        Ok(result) => result?,
        Err(_) => return Ok(()), // read timeout — close silently
    };
    if n == 0 {
        return Ok(());
    }

    let request = String::from_utf8_lossy(&buf[..n]);

    let first_line = match request.lines().next() {
        Some(line) => line,
        None => {
            stream
                .write_all(&build_response(400, "text/plain", b"Bad Request", &[]))
                .await?;
            return Ok(());
        }
    };

    let parts: Vec<&str> = first_line.split_whitespace().collect();
    if parts.len() < 2 || (parts[0] != "GET" && parts[0] != "HEAD") {
        stream
            .write_all(&build_response(
                405,
                "text/plain",
                b"Method Not Allowed",
                &[("Allow", "GET, HEAD")],
            ))
            .await?;
        return Ok(());
    }

    let is_head = parts[0] == "HEAD";

    let raw_path = parts[1];

    // Decode percent-encoding, strip query string and fragment
    let decoded = percent_encoding::percent_decode_str(raw_path)
        .decode_utf8_lossy()
        .to_string();
    let clean_path = decoded
        .split('?')
        .next()
        .unwrap_or(&decoded)
        .split('#')
        .next()
        .unwrap_or(&decoded);

    // Security: prevent path traversal (check segments, not substring)
    let normalized = clean_path.replace('\\', "/");
    if normalized.split('/').any(|seg| seg == "..") {
        stream
            .write_all(&build_response(403, "text/plain", b"Forbidden", &[]))
            .await?;
        return Ok(());
    }

    let relative_path = normalized.trim_start_matches('/');

    let mut response = if is_markdown {
        serve_markdown_request(root_dir, relative_path)
    } else {
        serve_offline_request(root_dir, relative_path)
    };

    // For HEAD requests, send only headers (Content-Length stays correct)
    if is_head && let Some(pos) = find_header_end(&response) {
        response.truncate(pos);
    }

    let status = extract_status(&response);
    let method = parts[0];
    let status_color = if status < 300 {
        "green"
    } else if status < 400 {
        "cyan"
    } else {
        "red"
    };
    println!(
        "  {} {} {}",
        utils::get_color_text(&format!("{}", status), status_color, false),
        method,
        raw_path,
    );

    stream.write_all(&response).await?;
    Ok(())
}

fn find_header_end(response: &[u8]) -> Option<usize> {
    response.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
}

fn extract_status(response: &[u8]) -> u16 {
    let header = String::from_utf8_lossy(&response[..std::cmp::min(30, response.len())]);
    header
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0)
}

// ---- Markdown serving ----

fn serve_markdown_request(root_dir: &Path, relative_path: &str) -> Vec<u8> {
    let csp = ("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data:");

    match resolve_markdown_path(root_dir, relative_path) {
        Some(path) if !is_within_root(root_dir, &path) => build_response(403, "text/plain", b"Forbidden", &[]),
        Some(path) if path.extension().is_some_and(|ext| ext == "md") => match std::fs::read_to_string(&path) {
            Ok(content) if content.trim().is_empty() => {
                // Empty markdown file — show directory listing instead
                let dir_path = path.parent().unwrap_or(root_dir);
                let url_path = relative_path
                    .trim_end_matches('/')
                    .trim_end_matches("index.md")
                    .trim_end_matches('/');
                let listing = directory_listing(dir_path, url_path, true);
                build_response(200, "text/html; charset=utf-8", listing.as_bytes(), &[csp])
            }
            Ok(content) => {
                let html = render_markdown_to_html(&content, relative_path);
                build_response(200, "text/html; charset=utf-8", html.as_bytes(), &[csp])
            }
            Err(_) => build_404_response(true),
        },
        Some(path) => serve_static_file(&path, &[csp]),
        None => {
            let dir_path = root_dir.join(relative_path);
            if dir_path.is_dir() && is_within_root(root_dir, &dir_path) {
                let listing = directory_listing(&dir_path, relative_path, true);
                build_response(200, "text/html; charset=utf-8", listing.as_bytes(), &[csp])
            } else {
                build_404_response(true)
            }
        }
    }
}

fn resolve_markdown_path(root_dir: &Path, relative_path: &str) -> Option<PathBuf> {
    if relative_path.is_empty() {
        let index = root_dir.join("index.md");
        if index.is_file() {
            return Some(index);
        }
        return None;
    }

    let full_path = root_dir.join(relative_path);

    // Direct file match (static files, .md files with extension in URL)
    if full_path.is_file() {
        return Some(full_path);
    }

    // Try adding .md extension
    let trimmed = relative_path.trim_end_matches('/');
    let md_path = root_dir.join(format!("{}.md", trimmed));
    if md_path.is_file() {
        return Some(md_path);
    }

    // Try as directory with index.md
    let index_path = full_path.join("index.md");
    if index_path.is_file() {
        return Some(index_path);
    }

    None
}

// ---- Offline serving ----

fn serve_offline_request(root_dir: &Path, relative_path: &str) -> Vec<u8> {
    let csp = ("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data:");

    match resolve_offline_path(root_dir, relative_path) {
        Some(path) if !is_within_root(root_dir, &path) => build_response(403, "text/plain", b"Forbidden", &[]),
        Some(path) => serve_static_file(&path, &[csp]),
        None => {
            let dir_path = root_dir.join(relative_path);
            if dir_path.is_dir() && is_within_root(root_dir, &dir_path) {
                let listing = directory_listing(&dir_path, relative_path, false);
                build_response(200, "text/html; charset=utf-8", listing.as_bytes(), &[csp])
            } else {
                build_404_response(false)
            }
        }
    }
}

fn resolve_offline_path(root_dir: &Path, relative_path: &str) -> Option<PathBuf> {
    if relative_path.is_empty() {
        let index = root_dir.join("index.html");
        if index.is_file() {
            return Some(index);
        }
        return None;
    }

    let full_path = root_dir.join(relative_path);

    // Direct file match
    if full_path.is_file() {
        return Some(full_path);
    }

    // Try as directory with index.html (prefer over .html redirect files)
    let dir_path = root_dir.join(relative_path.trim_end_matches('/'));
    let index_path = dir_path.join("index.html");
    if index_path.is_file() {
        return Some(index_path);
    }

    // Try with .html extension
    let trimmed = relative_path.trim_end_matches('/');
    let html_path = root_dir.join(format!("{}.html", trimmed));
    if html_path.is_file() {
        return Some(html_path);
    }

    None
}

// ---- Shared utilities ----

fn serve_static_file(path: &Path, extra_headers: &[(&str, &str)]) -> Vec<u8> {
    match std::fs::read(path) {
        Ok(content) => {
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            let content_type = content_type_for_extension(ext);
            build_response(200, content_type, &content, extra_headers)
        }
        Err(_) => build_response(500, "text/plain", b"Internal Server Error", &[]),
    }
}

/// Verify that the resolved path stays within the root directory (symlink-safe).
fn is_within_root(root_dir: &Path, resolved_path: &Path) -> bool {
    let Ok(canonical_root) = std::fs::canonicalize(root_dir) else {
        return false;
    };
    let Ok(canonical_path) = std::fs::canonicalize(resolved_path) else {
        return false;
    };
    canonical_path.starts_with(&canonical_root)
}

fn build_response(status: u16, content_type: &str, body: &[u8], extra_headers: &[(&str, &str)]) -> Vec<u8> {
    let status_text = match status {
        200 => "OK",
        301 => "Moved Permanently",
        400 => "Bad Request",
        403 => "Forbidden",
        404 => "Not Found",
        405 => "Method Not Allowed",
        500 => "Internal Server Error",
        _ => "Unknown",
    };

    let mut header = format!(
        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nX-Powered-By: siteone-crawler/{}\r\nX-Frame-Options: DENY\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",
        status,
        status_text,
        content_type,
        body.len(),
        version::CODE
    );

    for (name, value) in extra_headers {
        header.push_str(&format!("{}: {}\r\n", name, value));
    }

    header.push_str("\r\n");

    let mut response = header.into_bytes();
    response.extend_from_slice(body);
    response
}

fn build_404_response(is_markdown: bool) -> Vec<u8> {
    let body = if is_markdown {
        format!(
            "<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><title>404 Not Found</title>\n<style>{}</style>\n</head>\n<body>\n<div class=\"container\">\n<article class=\"markdown-body\">\n<h1>404 - Page Not Found</h1>\n<p>The requested page was not found.</p>\n<p><a href=\"/\">Back to home</a></p>\n</article>\n</div>\n</body>\n</html>",
            MARKDOWN_CSS
        )
    } else {
        "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1><p>The requested file was not found.</p></body></html>"
            .to_string()
    };
    build_response(404, "text/html; charset=utf-8", body.as_bytes(), &[])
}

fn content_type_for_extension(ext: &str) -> &'static str {
    // Extensions come from the filesystem and are almost always lowercase.
    // Use a small stack buffer to avoid heap allocation for the rare uppercase case.
    let mut lower = [0u8; 8];
    let ext_lower = if ext.len() <= 8 {
        for (i, b) in ext.bytes().enumerate() {
            lower[i] = b.to_ascii_lowercase();
        }
        std::str::from_utf8(&lower[..ext.len()]).unwrap_or(ext)
    } else {
        ext // fallback: will only match if already lowercase
    };
    match ext_lower {
        "html" | "htm" => "text/html; charset=utf-8",
        "css" => "text/css; charset=utf-8",
        "js" | "mjs" => "application/javascript; charset=utf-8",
        "json" => "application/json; charset=utf-8",
        "xml" => "application/xml; charset=utf-8",
        "txt" => "text/plain; charset=utf-8",
        "md" => "text/markdown; charset=utf-8",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "svg" => "image/svg+xml; charset=utf-8",
        "ico" => "image/x-icon",
        "webp" => "image/webp",
        "avif" => "image/avif",
        "woff" => "font/woff",
        "woff2" => "font/woff2",
        "ttf" => "font/ttf",
        "otf" => "font/otf",
        "eot" => "application/vnd.ms-fontobject",
        "pdf" => "application/pdf",
        "zip" => "application/zip",
        "mp4" => "video/mp4",
        "webm" => "video/webm",
        "mp3" => "audio/mpeg",
        _ => "application/octet-stream",
    }
}

// ---- Markdown rendering ----

fn render_markdown_to_html(markdown: &str, request_path: &str) -> String {
    use pulldown_cmark::{Options, Parser, html};

    // Replace curly/smart quotes with straight quotes
    let markdown = markdown
        .replace(['\u{201c}', '\u{201d}'], "\"")
        .replace(['\u{2018}', '\u{2019}'], "'");

    // Clean up markdown artifacts from HTML→MD conversion
    let cleaned = clean_markdown_artifacts(&markdown);

    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TASKLISTS);

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

    // Add id attributes to h1-h4 headings for anchor linking
    html_content = add_heading_ids(&html_content);

    // Convert heading + link-only blocks (>3 links) into accordions
    html_content = collapse_link_blocks(&html_content);

    // Add link counts to existing Menu/Links accordions
    html_content = add_accordion_link_counts(&html_content);

    // Style callout blocks (Tip, Note, Caution, etc.)
    html_content = style_callout_blocks(&html_content);

    // Extract title from first heading in the cleaned markdown
    let heading = extract_title(&cleaned);
    let title = if heading == "SiteOne Crawler - Markdown Viewer" {
        heading
    } else {
        format!("{} | SiteOne Crawler - Markdown Viewer", heading)
    };

    // Build breadcrumb navigation
    let breadcrumb = build_breadcrumb(request_path);

    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="SiteOne Crawler - https://crawler.siteone.io/">
<title>{title}</title>
<style>
{css}
</style>
<script>
(function(){{
  var t=localStorage.getItem('md-theme'),w=localStorage.getItem('md-width');
  if(t==='dark')document.documentElement.classList.add('dark');
  if(w==='wide')document.documentElement.classList.add('wide');
}})();
</script>
</head>
<body>
<div class="container">
<nav class="breadcrumb">
<span class="breadcrumb-path">{breadcrumb}</span>
<span class="toolbar">
<button onclick="toggleWidth()" id="width-btn" title="Toggle full width"></button>
<button onclick="toggleTheme()" id="theme-btn" title="Toggle dark/light mode"></button>
</span>
</nav>
<article class="markdown-body">
{content}
</article>
<footer>
<p>Served by <a href="https://crawler.siteone.io/" target="_blank" rel="noopener">SiteOne Crawler</a> v{version}</p>
</footer>
</div>
<script>
var svgMoon='<svg viewBox="0 0 16 16" width="14" height="14"><path d="M6 .5a7.5 7.5 0 1 0 8 12A6 6 0 0 1 6 .5z" fill="currentColor"/></svg>';
var svgSun='<svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.8" fill="currentColor"/><g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="8" y1=".5" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15.5"/><line x1=".5" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15.5" y2="8"/><line x1="2.7" y1="2.7" x2="4.5" y2="4.5"/><line x1="11.5" y1="11.5" x2="13.3" y2="13.3"/><line x1="2.7" y1="13.3" x2="4.5" y2="11.5"/><line x1="11.5" y1="4.5" x2="13.3" y2="2.7"/></g></svg>';
var svgExpand='<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8h14M4.5 5L1 8l3.5 3M11.5 5L15 8l-3.5 3"/></svg>';
var svgContract='<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8h5M10 8h5M3.5 5L7 8l-3.5 3M12.5 5L9 8l3.5 3"/></svg>';
function toggleTheme(){{
  document.documentElement.classList.toggle('dark');
  localStorage.setItem('md-theme',document.documentElement.classList.contains('dark')?'dark':'light');
  updBtn();
}}
function toggleWidth(){{
  document.documentElement.classList.toggle('wide');
  localStorage.setItem('md-width',document.documentElement.classList.contains('wide')?'wide':'narrow');
  updBtn();
}}
function updBtn(){{
  var d=document.documentElement.classList.contains('dark');
  var w=document.documentElement.classList.contains('wide');
  document.getElementById('theme-btn').innerHTML=d?svgSun:svgMoon;
  document.getElementById('width-btn').innerHTML=w?svgContract:svgExpand;
}}
updBtn();
</script>
</body>
</html>"#,
        title = html_escape(&title),
        css = MARKDOWN_CSS,
        breadcrumb = breadcrumb,
        content = html_content,
        version = version::CODE,
    )
}

/// Clean up common artifacts left by HTML→Markdown export.
fn clean_markdown_artifacts(markdown: &str) -> String {
    let lines: Vec<&str> = markdown.lines().collect();
    let mut result: Vec<&str> = Vec::with_capacity(lines.len());
    let mut in_code_block = false;

    // Phase 1: Skip site navigation header before the first h1 heading.
    // If there are 3+ links before the first h1, the content is likely nav/header.
    // But preserve <details> blocks (accordion menus) from the navigation.
    let mut content_start = 0;
    {
        let mut link_count = 0;
        for idx in 0..lines.len() {
            let t = lines[idx].trim();
            if t.starts_with("```") {
                break; // don't look inside code blocks
            }

            // Check for setext h1 (line followed by ===)
            if idx + 1 < lines.len() {
                let next = lines[idx + 1].trim();
                if !next.is_empty() && next.len() >= 3 && next.chars().all(|c| c == '=') {
                    if link_count >= 3 {
                        content_start = idx;
                    }
                    break;
                }
            }
            // Check for ATX h1
            if t.starts_with("# ") && !t.starts_with("## ") {
                if link_count >= 3 {
                    content_start = idx;
                }
                break;
            }

            // Count links in non-heading lines
            link_count += t.matches("](").count();
        }
    }

    // Phase 1b: Preserve navigation from the skipped section.
    // If the section already contains <details> blocks, preserve them as-is.
    // Otherwise, wrap the entire nav content in a <details><summary>Menu</summary> block.
    if content_start > 0 {
        let has_details = lines[..content_start]
            .iter()
            .any(|l| l.trim() == "<details>" || l.trim().starts_with("<details>"));

        if has_details {
            // Preserve existing <details> blocks (e.g. astro Menu accordion)
            let mut k = 0;
            while k < content_start {
                let t = lines[k].trim();
                if t == "<details>" || t.starts_with("<details>") {
                    while k < content_start {
                        result.push(lines[k]);
                        k += 1;
                        if lines[k - 1].trim() == "</details>" {
                            break;
                        }
                    }
                } else {
                    k += 1;
                }
            }
        } else {
            // No <details> blocks — wrap nav content in an accordion.
            // Collect non-empty, non-artifact lines as the accordion body.
            let mut nav_lines: Vec<&str> = Vec::new();
            for line in &lines[..content_start] {
                let t = line.trim();
                // Skip header artifacts
                if t.is_empty()
                    || t == "-"
                    || t.starts_with("[Skip to content]")
                    || (t.starts_with("| [") && t.ends_with(" |"))
                {
                    continue;
                }
                // Skip setext underlines (--- or ===)
                if t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '=')) {
                    continue;
                }
                // Skip heading text that is just "Site navigation" or similar
                if t == "Site navigation" || t == "Navigation" {
                    continue;
                }
                nav_lines.push(line);
            }
            // Trim trailing non-link plain-text lines (breadcrumb labels etc.)
            while let Some(last) = nav_lines.last() {
                let t = last.trim();
                if !t.contains("](") && !t.starts_with("- ") && !t.starts_with("### ") {
                    nav_lines.pop();
                } else {
                    break;
                }
            }
            if nav_lines.len() > 3 {
                result.push("<details>");
                result.push("<summary>Menu</summary>");
                result.push("");
                for line in &nav_lines {
                    result.push(line);
                }
                result.push("");
                result.push("</details>");
                result.push(""); // empty line so pulldown-cmark starts a new block
            }
        }
    }

    // Phase 2: Process lines from content_start, filtering artifacts
    let mut i = content_start;
    while i < lines.len() {
        let trimmed = lines[i].trim();

        // Track code blocks to avoid filtering inside them
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            result.push(lines[i]);
            i += 1;
            continue;
        }
        if in_code_block {
            result.push(lines[i]);
            i += 1;
            continue;
        }

        // Skip empty list items (just "-" with no text content)
        if trimmed == "-" {
            i += 1;
            continue;
        }

        // Skip [Section titled "..."](...) lines
        if trimmed.starts_with("[Section titled \"") && trimmed.ends_with(')') {
            i += 1;
            continue;
        }

        // Skip [Skip to content](...) variants
        if trimmed.starts_with("[Skip to content]") {
            i += 1;
            continue;
        }

        // Detect footer: "[Go to ... homepage](...)" standalone link
        if !trimmed.starts_with("- ")
            && trimmed.starts_with("[Go to ")
            && trimmed.to_lowercase().contains("homepage")
            && trimmed.ends_with(')')
        {
            break;
        }

        // Skip "On this page" heading + its following <details> block
        if (trimmed == "On this page" || trimmed == "On this page:")
            && i + 1 < lines.len()
            && lines[i + 1].trim().starts_with('-')
        {
            // Skip underline-style heading (--- below)
            i += 1;
            if i < lines.len() && lines[i].trim().starts_with('-') && lines[i].trim().chars().all(|c| c == '-') {
                i += 1;
            }
            // Skip until next heading or end of <details> block
            while i < lines.len() {
                let t = lines[i].trim();
                if t.starts_with("# ")
                    || t.starts_with("## ")
                    || (t.starts_with('#') && t.chars().nth(1).is_some_and(|c| c == '#' || c == ' '))
                {
                    break;
                }
                if t == "</details>" {
                    i += 1;
                    break;
                }
                i += 1;
            }
            // Skip empty lines after the block
            while i < lines.len() && lines[i].trim().is_empty() {
                i += 1;
            }
            continue;
        }

        // Skip footer artifacts: "Learn" heading (astro docs pattern)
        if trimmed == "Learn"
            && i + 1 < lines.len()
            && (lines.get(i + 1).is_some_and(|l| l.trim().is_empty())
                || lines.get(i + 2).is_some_and(|l| l.trim().starts_with("| [")))
        {
            break;
        }

        result.push(lines[i]);
        i += 1;
    }

    // Phase 3: Fix broken code fences — detect unfenced code blocks between
    // a closing ``` and an opening ```lang (or at end of content).
    // The HTML→MD converter sometimes misses fences for tabbed code examples.
    let mut fixed: Vec<&str> = Vec::with_capacity(result.len());
    let mut ri = 0;
    let result_lines: Vec<&str> = result; // take ownership
    let mut in_fence = false;
    while ri < result_lines.len() {
        let t = result_lines[ri].trim();
        if t.starts_with("```") {
            in_fence = !in_fence;
            fixed.push(result_lines[ri]);
            ri += 1;
            continue;
        }
        if in_fence {
            fixed.push(result_lines[ri]);
            ri += 1;
            continue;
        }

        // Not inside a fence — check if this starts an unfenced code block.
        // Heuristic: if the previous non-empty line was a closing ```,
        // and this line looks like code (contains semicolons, braces, =>, etc.),
        // collect all lines until the next ``` or blank-line gap.
        let prev_is_fence_close = fixed
            .iter()
            .rev()
            .find(|l| !l.trim().is_empty())
            .is_some_and(|l| l.trim().starts_with("```"));

        if prev_is_fence_close && !t.is_empty() && looks_like_code(t) {
            // Collect unfenced code lines
            let start = ri;
            while ri < result_lines.len() {
                let lt = result_lines[ri].trim();
                if lt.starts_with("```") {
                    break;
                }
                // Stop if we hit a markdown heading or a completely blank line
                // followed by non-code content
                if lt.is_empty() {
                    // Look ahead: if the next non-empty line is not code-like, stop
                    let mut peek = ri + 1;
                    while peek < result_lines.len() && result_lines[peek].trim().is_empty() {
                        peek += 1;
                    }
                    if peek < result_lines.len() && !looks_like_code(result_lines[peek].trim()) {
                        break;
                    }
                }
                if lt.starts_with("# ") || lt.starts_with("## ") || lt.starts_with("### ") {
                    break;
                }
                ri += 1;
            }
            // Trim trailing empty lines from the collected block
            let mut end = ri;
            while end > start && result_lines[end - 1].trim().is_empty() {
                end -= 1;
            }
            if end > start {
                fixed.push("```");
                for line in &result_lines[start..end] {
                    fixed.push(line);
                }
                fixed.push("```");
            }
            // If the next line is an opening ```, it will be handled normally
            continue;
        }

        fixed.push(result_lines[ri]);
        ri += 1;
    }

    fixed.join("\n")
}

/// Heuristic: does this line look like source code?
/// Requires at least 2 code indicators to reduce false positives on prose.
fn looks_like_code(line: &str) -> bool {
    let mut score = 0;
    // Strong indicators (score 2) — very unlikely in prose
    if line.contains("=> {") || line.contains("=> (") {
        score += 2;
    }
    if line.contains("};") {
        score += 2;
    }
    if line.ends_with(';') {
        score += 2;
    }
    if line.starts_with("//") {
        score += 2;
    }
    if line.starts_with("if (") || line.starts_with("if (!") {
        score += 2;
    }
    if line.contains("?.") {
        score += 2;
    } // optional chaining
    if line.contains("===") || line.contains("!==") {
        score += 2;
    }
    // Moderate indicators (score 1)
    if line.contains("export ") {
        score += 1;
    }
    if line.contains("const ") {
        score += 1;
    }
    if line.contains("return ") {
        score += 1;
    }
    if line.contains("await ") {
        score += 1;
    }
    if line.contains("async ") {
        score += 1;
    }
    if line.contains("function ") {
        score += 1;
    }
    if line.ends_with('{') || line.ends_with('}') {
        score += 1;
    }
    score >= 2
}

/// Convert standalone callout paragraphs (Tip, Note, Caution, etc.) into styled divs.
fn style_callout_blocks(html: &str) -> String {
    let callout_patterns: [(&str, &str); 6] = [
        ("<p>Tip</p>", "Tip"),
        ("<p>Note</p>", "Note"),
        ("<p>Caution</p>", "Caution"),
        ("<p>Warning</p>", "Warning"),
        ("<p>Important</p>", "Important"),
        ("<p>Quick start</p>", "Quick start"),
    ];
    let mut result = String::with_capacity(html.len() + 512);
    let lines: Vec<&str> = html.lines().collect();
    let mut i = 0;

    while i < lines.len() {
        let trimmed = lines[i].trim();

        // Match <p>Label</p> followed by <p>content</p>
        let mut matched_label = None;
        for (pattern, label) in &callout_patterns {
            if trimmed == *pattern {
                matched_label = Some(*label);
                break;
            }
        }

        if let Some(label) = matched_label {
            let icon = match label {
                "Tip" => "💡",
                "Note" | "Important" => "📝",
                "Caution" | "Warning" => "⚠️",
                "Quick start" => "🚀",
                _ => "ℹ️",
            };
            let css_class = match label {
                "Caution" | "Warning" => "callout callout-warning",
                "Tip" | "Quick start" => "callout callout-tip",
                _ => "callout callout-note",
            };
            // Collect following paragraphs as callout content
            result.push_str(&format!(
                "<div class=\"{}\">\n<p class=\"callout-title\">{} {}</p>\n",
                css_class, icon, label
            ));
            i += 1;
            // Include subsequent content paragraphs until next heading or another callout
            while i < lines.len() {
                let next = lines[i].trim();
                if next.is_empty() {
                    i += 1;
                    continue;
                }
                // Stop at headings, details, or another callout label
                if next.starts_with("<h") || next.starts_with("<details") || next.starts_with("<div class=\"callout") {
                    break;
                }
                // Include this line in the callout
                result.push_str(lines[i]);
                result.push('\n');
                i += 1;
                // Only include the first content element
                break;
            }
            result.push_str("</div>\n");
        } else {
            result.push_str(lines[i]);
            result.push('\n');
            i += 1;
        }
    }

    result
}

fn extract_title(markdown: &str) -> String {
    let lines: Vec<&str> = markdown.lines().collect();
    let mut in_code_block = false;
    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            continue;
        }
        if in_code_block {
            continue;
        }
        // ATX h1: # Heading
        if let Some(heading) = trimmed.strip_prefix("# ") {
            return heading.trim().to_string();
        }
        // Setext h1: text followed by === on the next line
        if !trimmed.is_empty()
            && i + 1 < lines.len()
            && lines[i + 1].trim().len() >= 3
            && lines[i + 1].trim().chars().all(|c| c == '=')
        {
            return trimmed.to_string();
        }
    }
    "SiteOne Crawler - Markdown Viewer".to_string()
}

fn add_heading_ids(html: &str) -> String {
    let closing_tags: [&str; 4] = ["</h1>", "</h2>", "</h3>", "</h4>"];
    let mut result = String::with_capacity(html.len() + 256);
    let mut used_slugs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    let mut search_from = 0;

    while search_from < html.len() {
        // Find next '<h' pattern
        let remaining = &html[search_from..];
        let next_h = remaining.find("<h").and_then(|pos| {
            let abs = search_from + pos;
            let bytes = html.as_bytes();
            if abs + 3 < bytes.len() && bytes[abs + 2] >= b'1' && bytes[abs + 2] <= b'4' && bytes[abs + 3] == b'>' {
                Some((abs, (bytes[abs + 2] - b'1') as usize))
            } else {
                None
            }
        });

        match next_h {
            Some((tag_start, level_idx)) => {
                let tag_end = tag_start + 4; // past '>'
                let closing_tag = closing_tags[level_idx];
                if let Some(close_rel) = html[tag_end..].find(closing_tag) {
                    let inner_html = &html[tag_end..tag_end + close_rel];
                    let plain_text = strip_html_tags(inner_html);
                    let base_slug = slugify(&plain_text);

                    // Copy everything before this heading
                    result.push_str(&html[search_from..tag_start]);

                    if !base_slug.is_empty() {
                        // Deduplicate slug
                        let slug = match used_slugs.get(&base_slug) {
                            Some(&count) => {
                                let deduped = format!("{}-{}", base_slug, count);
                                *used_slugs.get_mut(&base_slug).unwrap() = count + 1;
                                deduped
                            }
                            None => {
                                used_slugs.insert(base_slug.clone(), 1);
                                base_slug
                            }
                        };
                        let escaped_slug = html_escape(&slug);
                        result.push_str(&format!(
                            "<h{0} id=\"{1}\"><a href=\"#{1}\" class=\"heading-link\">",
                            level_idx + 1,
                            escaped_slug
                        ));
                        result.push_str(inner_html);
                        result.push_str("</a>");
                        result.push_str(closing_tag);
                    } else {
                        result.push_str(&html[tag_start..tag_end]);
                        result.push_str(inner_html);
                        result.push_str(closing_tag);
                    }
                    search_from = tag_end + close_rel + closing_tag.len();
                } else {
                    // No closing tag found — emit the opening tag and continue
                    result.push_str(&html[search_from..tag_end]);
                    search_from = tag_end;
                }
            }
            None => {
                // No more headings — copy rest and done
                result.push_str(&html[search_from..]);
                break;
            }
        }
    }

    result
}

/// Detect heading level from an HTML line like `<h2 ...>` or `<h2>`.
fn detect_heading_level(line: &str) -> Option<u8> {
    let t = line.trim();
    if t.starts_with("<h") && t.len() > 3 {
        let ch = t.as_bytes()[2];
        if (b'1'..=b'6').contains(&ch) && (t.as_bytes()[3] == b'>' || t.as_bytes()[3] == b' ') {
            return Some(ch - b'0');
        }
    }
    None
}

/// When a heading is followed only by link-only content (paragraphs or lists)
/// and there are more than 3 links, collapse them into an accordion.
/// Also handles sub-headings + link blocks grouped under a parent heading.
fn collapse_link_blocks(html: &str) -> String {
    let lines: Vec<&str> = html.lines().collect();
    let mut result = String::with_capacity(html.len() + 512);
    let mut i = 0;
    let mut details_depth = 0; // track nesting inside <details> blocks

    while i < lines.len() {
        let trimmed = lines[i].trim();

        // Track <details> nesting — don't create accordions inside existing ones
        if trimmed.starts_with("<details") {
            details_depth += 1;
        }
        if trimmed == "</details>" && details_depth > 0 {
            details_depth -= 1;
        }

        let heading_level = detect_heading_level(trimmed);

        if details_depth > 0 {
            // Inside an existing <details> block — pass through without collapsing
            result.push_str(lines[i]);
            result.push('\n');
            i += 1;
            continue;
        }

        if let Some(level) = heading_level {
            let heading_line = lines[i];
            let closing = format!("</h{}>", level);
            let heading_text = if let Some(start) = heading_line.find('>') {
                let after = &heading_line[start + 1..];
                if let Some(end) = after.find(&closing) {
                    strip_html_tags(&after[..end]).trim().to_string()
                } else {
                    String::new()
                }
            } else {
                String::new()
            };

            // Scan forward: collect link-only content blocks
            // Allowed: empty lines, link-only <p>, link-only <ul>, sub-headings
            let mut j = i + 1;
            let mut link_count = 0;
            let mut content_indices: Vec<(usize, usize)> = Vec::new(); // (start, end) ranges
            let mut all_link_only = true;

            while j < lines.len() {
                let next = lines[j].trim();

                if next.is_empty() {
                    content_indices.push((j, j + 1));
                    j += 1;
                    continue;
                }

                // Stop at same-or-higher-level heading
                if let Some(next_level) = detect_heading_level(next) {
                    if next_level <= level {
                        break;
                    }
                    // Sub-heading within this section — include it
                    content_indices.push((j, j + 1));
                    j += 1;
                    continue;
                }

                // Link-only paragraph
                if is_link_only_paragraph(next) {
                    link_count += next.matches("<a ").count();
                    content_indices.push((j, j + 1));
                    j += 1;
                    continue;
                }

                // Link-only <ul> block
                if next == "<ul>" {
                    let ul_start = j;
                    let mut ul_links = 0;
                    let mut ul_ok = true;
                    let mut k = j + 1;
                    while k < lines.len() {
                        let ul_line = lines[k].trim();
                        if ul_line == "</ul>" {
                            k += 1;
                            break;
                        }
                        if ul_line.starts_with("<li>") && ul_line.contains("<a ") {
                            ul_links += 1;
                        } else if ul_line.starts_with("<li>") && ul_line != "<li></li>" {
                            // Non-link list item with content
                            let inner_text = strip_html_tags(ul_line);
                            if !inner_text.trim().is_empty() {
                                ul_ok = false;
                            }
                        }
                        k += 1;
                    }

                    if ul_ok && ul_links > 0 {
                        link_count += ul_links;
                        content_indices.push((ul_start, k));
                        j = k;
                        continue;
                    }

                    all_link_only = false;
                    break;
                }

                // Any other content → stop
                all_link_only = false;
                break;
            }

            if all_link_only && link_count > 3 && !heading_text.is_empty() {
                result.push_str(&format!(
                    "<details>\n<summary>{} ({} links)</summary>\n",
                    html_escape(&heading_text),
                    link_count
                ));
                for (start, end) in &content_indices {
                    for line in lines.iter().take(*end).skip(*start) {
                        result.push_str(line);
                        result.push('\n');
                    }
                }
                result.push_str("</details>\n");
                i = j;
            } else {
                result.push_str(heading_line);
                result.push('\n');
                i += 1;
            }
        } else {
            result.push_str(lines[i]);
            result.push('\n');
            i += 1;
        }
    }

    result
}

/// Check if an HTML line is a `<p>` containing only `<a>` links.
fn is_link_only_paragraph(line: &str) -> bool {
    let trimmed = line.trim();
    if !trimmed.starts_with("<p>") || !trimmed.ends_with("</p>") {
        return false;
    }
    let inner = &trimmed[3..trimmed.len() - 4];
    if !inner.contains("<a ") {
        return false;
    }
    let mut remaining = inner.to_string();
    while let Some(start) = remaining.find("<a ") {
        if let Some(end) = remaining[start..].find("</a>") {
            remaining = format!("{}{}", &remaining[..start], &remaining[start + end + 4..]);
        } else {
            break;
        }
    }
    remaining.trim().is_empty()
}

/// Find `<details>` blocks whose `<summary>` is "Menu" or "Links" and append link count.
fn add_accordion_link_counts(html: &str) -> String {
    let mut result = String::with_capacity(html.len() + 256);
    let mut search_from = 0;

    while let Some(details_start) = html[search_from..].find("<details>") {
        let abs_start = search_from + details_start;
        // Copy everything before this <details>
        result.push_str(&html[search_from..abs_start]);

        // Find matching </details> (respecting nesting)
        let after_tag = abs_start + "<details>".len();
        let details_end = {
            let mut depth = 1;
            let mut scan = after_tag;
            loop {
                let next_open = html[scan..].find("<details>");
                let next_close = html[scan..].find("</details>");
                match next_close {
                    Some(close_rel) => {
                        if let Some(open_rel) = next_open
                            && open_rel < close_rel
                        {
                            depth += 1;
                            scan += open_rel + "<details>".len();
                        } else {
                            depth -= 1;
                            scan += close_rel + "</details>".len();
                            if depth == 0 {
                                break;
                            }
                        }
                    }
                    None => break,
                }
            }
            if depth == 0 { Some(scan) } else { None }
        };
        if let Some(details_end) = details_end {
            let block = &html[abs_start..details_end];

            // Check if summary is "Menu" or "Links"
            if let Some(summary_start) = block.find("<summary>")
                && let Some(summary_end) = block.find("</summary>")
            {
                let summary_text = &block[summary_start + "<summary>".len()..summary_end];
                let trimmed_summary = summary_text.trim();

                if trimmed_summary == "Menu" || trimmed_summary == "Links" {
                    // Count <a> links inside the block
                    let link_count = block.matches("<a ").count() + block.matches("<a\n").count();
                    if link_count > 0 {
                        // Rebuild block with count in summary
                        let new_summary = format!("<summary>{} ({} links)</summary>", trimmed_summary, link_count);
                        let before_summary = &block[..summary_start];
                        let after_summary = &block[summary_end + "</summary>".len()..];
                        result.push_str(before_summary);
                        result.push_str(&new_summary);
                        result.push_str(after_summary);
                        search_from = details_end;
                        continue;
                    }
                }
            }

            // Not a Menu/Links accordion — emit as-is
            result.push_str(block);
            search_from = details_end;
        } else {
            // No closing </details> — emit rest as-is
            result.push_str(&html[abs_start..]);
            search_from = html.len();
        }
    }

    // Copy remaining text
    result.push_str(&html[search_from..]);
    result
}

fn strip_html_tags(html: &str) -> String {
    let mut result = String::with_capacity(html.len());
    let mut in_tag = false;
    for ch in html.chars() {
        if ch == '<' {
            in_tag = true;
        } else if ch == '>' {
            in_tag = false;
        } else if !in_tag {
            result.push(ch);
        }
    }
    result
}

fn slugify(text: &str) -> String {
    text.to_lowercase()
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c
            } else if c == ' ' || c == '_' || c == '-' {
                '-'
            } else {
                '\0'
            }
        })
        .filter(|c| *c != '\0')
        .collect::<String>()
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

fn build_breadcrumb(request_path: &str) -> String {
    let mut parts = vec![r#"<a href="/">Home</a>"#.to_string()];

    let clean = request_path
        .trim_end_matches(".md")
        .trim_end_matches("/index")
        .trim_end_matches('/');

    if !clean.is_empty() {
        let segments: Vec<&str> = clean.split('/').filter(|s| !s.is_empty()).collect();
        let mut accumulated = String::new();
        for (i, segment) in segments.iter().enumerate() {
            accumulated.push('/');
            accumulated.push_str(segment);
            let display = title_case_segment(segment);
            if i == segments.len() - 1 {
                parts.push(format!("<span>{}</span>", html_escape(&display)));
            } else {
                parts.push(format!(
                    r#"<a href="{}">{}</a>"#,
                    html_escape(&accumulated),
                    html_escape(&display)
                ));
            }
        }
    }

    parts.join(" / ")
}

/// Convert URL path segment to Title Case: "marketing-sites" → "Marketing Sites"
fn title_case_segment(segment: &str) -> String {
    segment
        .split('-')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(c) => {
                    let mut s = c.to_uppercase().to_string();
                    s.extend(chars);
                    s
                }
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

// ---- Directory listing ----

fn directory_listing(dir_path: &Path, url_path: &str, is_markdown: bool) -> String {
    let mut entries: Vec<(String, bool)> = Vec::new();

    if let Ok(read_dir) = std::fs::read_dir(dir_path) {
        for entry in read_dir.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            let is_dir = entry.path().is_dir();
            entries.push((name, is_dir));
        }
    }

    // Directories first, then alphabetical
    entries.sort_by(|a, b| match (a.1, b.1) {
        (true, false) => std::cmp::Ordering::Less,
        (false, true) => std::cmp::Ordering::Greater,
        _ => a.0.to_lowercase().cmp(&b.0.to_lowercase()),
    });

    let url_base = if url_path.is_empty() {
        String::new()
    } else {
        format!("/{}", url_path.trim_end_matches('/'))
    };

    let mut items = String::new();

    if !url_path.is_empty() {
        items.push_str("<li class=\"dir\"><a href=\"..\">..</a></li>\n");
    }

    for (name, is_dir) in &entries {
        let css_class = if *is_dir { "dir" } else { "file" };
        let href = if *is_dir {
            format!("{}/{}/", url_base, name)
        } else {
            format!("{}/{}", url_base, name)
        };
        let display = if *is_dir { format!("{}/", name) } else { name.clone() };
        items.push_str(&format!(
            "<li class=\"{}\"><a href=\"{}\">{}</a></li>\n",
            css_class,
            html_escape(&href),
            html_escape(&display),
        ));
    }

    let title = if url_path.is_empty() {
        "Index".to_string()
    } else {
        format!("/{}", url_path)
    };

    if is_markdown {
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} - Directory</title>
<style>
{css}
</style>
</head>
<body>
<div class="container">
<nav class="breadcrumb"><a href="/">Home</a></nav>
<article class="markdown-body">
<h1>{title}</h1>
<ul class="directory-listing">
{items}
</ul>
</article>
</div>
</body>
</html>"#,
            title = html_escape(&title),
            css = MARKDOWN_CSS,
            items = items,
        )
    } else {
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{title} - Directory</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px 40px; color: #24292e; }}
a {{ color: #0366d6; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
ul {{ list-style: none; padding: 0; }}
li {{ padding: 4px 0; }}
li.dir a::before {{ content: "[ ] "; font-family: monospace; }}
li.file a::before {{ content: "  - "; font-family: monospace; }}
</style>
</head>
<body>
<h1>{title}</h1>
<ul>
{items}
</ul>
</body>
</html>"#,
            title = html_escape(&title),
            items = items,
        )
    }
}

// ---- CSS Theme ----

const MARKDOWN_CSS: &str = r##"
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
    font-size: 15px;
    line-height: 1.6;
    color: #1f2328;
    background: #fff;
    -webkit-font-smoothing: antialiased;
}

.container {
    max-width: 860px;
    margin: 0 auto;
    padding: 16px 32px 32px;
}

/* Breadcrumb & Toolbar */
.breadcrumb {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 0;
    margin-bottom: 16px;
    border-bottom: 1px solid #d1d9e0;
    font-size: 13px;
    color: #656d76;
}
.breadcrumb a { color: #0969da; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.breadcrumb-path span { color: #1f2328; font-weight: 500; }
.toolbar { display: flex; gap: 4px; }
.toolbar button {
    background: none; border: 1px solid #d1d9e0; border-radius: 6px;
    cursor: pointer; font-size: 16px; width: 32px; height: 28px;
    display: flex; align-items: center; justify-content: center;
    color: #656d76; transition: background .15s;
}
.toolbar button:hover { background: #f3f4f6; }

/* Headings */
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
    margin-top: 1.5em;
    margin-bottom: 0.5em;
    font-weight: 600;
    line-height: 1.3;
    color: #1f2328;
}
.markdown-body h1 { font-size: 1.85em; padding-bottom: .25em; border-bottom: 1px solid #d1d9e0; margin-top: 0; }
.markdown-body h2 { font-size: 1.4em; padding-bottom: .2em; border-bottom: 1px solid #d1d9e0; }
.markdown-body h3 { font-size: 1.15em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5, .markdown-body h6 { font-size: .9em; color: #656d76; }
.markdown-body h1:first-child { margin-top: 0; }

/* Heading anchor links */
.markdown-body h1[id], .markdown-body h2[id], .markdown-body h3[id], .markdown-body h4[id] {
    position: relative;
}
.markdown-body .heading-link,
.markdown-body .heading-link:hover,
.markdown-body .heading-link:visited,
.markdown-body .heading-link:active {
    color: inherit;
    text-decoration: none;
}
.markdown-body h1[id]:hover::before, .markdown-body h2[id]:hover::before,
.markdown-body h3[id]:hover::before, .markdown-body h4[id]:hover::before {
    content: "#";
    position: absolute;
    left: -1.2em;
    color: #0969da;
    font-weight: 400;
}

/* Paragraphs and text */
.markdown-body p { margin-bottom: 12px; }
.markdown-body > p:last-child { margin-bottom: 0; }

.markdown-body a { color: #0969da; text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; color: #0550ae; }
.markdown-body a:visited { color: #6639ba; }

.markdown-body strong { font-weight: 600; }
.markdown-body em { font-style: italic; }
.markdown-body del { color: #656d76; }

/* Inline code */
.markdown-body code {
    padding: .15em .35em;
    font-size: 84%;
    background-color: #eff1f3;
    border-radius: 4px;
    font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
    color: #1f2328;
}

/* Code blocks */
.markdown-body pre {
    padding: 14px 16px;
    overflow: auto;
    font-size: 84%;
    line-height: 1.5;
    background-color: #f6f8fa;
    border-radius: 8px;
    border: 1px solid #d1d9e0;
    margin-bottom: 14px;
}
.markdown-body pre code {
    display: block;
    padding: 0;
    overflow: visible;
    background: transparent;
    border: 0;
    font-size: 100%;
    color: inherit;
    border-radius: 0;
}

/* Tables */
.markdown-body table {
    border-collapse: collapse;
    width: 100%;
    margin-bottom: 14px;
    display: block;
    overflow-x: auto;
    font-size: 14px;
}
.markdown-body th, .markdown-body td {
    padding: 6px 12px;
    border: 1px solid #d1d9e0;
}
.markdown-body th {
    font-weight: 600;
    background-color: #f6f8fa;
    text-align: left;
}
.markdown-body tr:nth-child(2n) { background-color: #f6f8fa; }

/* Blockquotes */
.markdown-body blockquote {
    padding: 4px 16px;
    color: #656d76;
    border-left: 3px solid #d1d9e0;
    margin-bottom: 12px;
}
.markdown-body blockquote p { margin-bottom: 4px; }
.markdown-body blockquote p:last-child { margin-bottom: 0; }

/* Lists — compact */
.markdown-body ul, .markdown-body ol {
    padding-left: 1.5em;
    margin-bottom: 10px;
}
.markdown-body li {
    margin: 1px 0;
    line-height: 1.5;
}
.markdown-body li > p { margin-bottom: 4px; }
.markdown-body li > ul, .markdown-body li > ol {
    margin-top: 2px;
    margin-bottom: 2px;
}

/* Task lists */
.markdown-body input[type="checkbox"] {
    margin-right: .4em;
    vertical-align: middle;
    position: relative;
    top: -1px;
}

/* Images */
.markdown-body img {
    max-width: 100%;
    height: auto;
    border-style: none;
    border-radius: 6px;
}

/* Horizontal rules */
.markdown-body hr {
    height: 2px;
    padding: 0;
    margin: 20px 0;
    background-color: #d1d9e0;
    border: 0;
}

/* Accordions (details/summary) */
.markdown-body details {
    margin: 12px 0;
    border: 1px solid #d1d9e0;
    border-radius: 8px;
    padding: 0;
    overflow: hidden;
}
.markdown-body details summary {
    cursor: pointer;
    font-weight: 600;
    font-size: 14px;
    padding: 8px 14px;
    background-color: #f6f8fa;
    user-select: none;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 6px;
}
.markdown-body details summary::before {
    content: "▶";
    font-size: 10px;
    color: #656d76;
    transition: transform .15s ease;
    display: inline-block;
    flex-shrink: 0;
}
.markdown-body details[open] summary::before {
    transform: rotate(90deg);
}
.markdown-body details summary::-webkit-details-marker { display: none; }
.markdown-body details[open] summary {
    border-bottom: 1px solid #d1d9e0;
}
.markdown-body details summary:hover {
    background-color: #eaeef2;
}
.markdown-body details > :not(summary) {
    padding: 0 14px;
}
.markdown-body details > p:first-of-type {
    margin-top: 10px;
}
.markdown-body details > ul, .markdown-body details > ol {
    padding: 8px 14px 6px 32px;
}
.markdown-body details > ul li, .markdown-body details > ol li {
    font-size: 14px;
}

/* Callout boxes (Tip, Note, Caution, etc.) */
.callout {
    margin: 14px 0;
    padding: 12px 16px;
    border-radius: 8px;
    border-left: 4px solid;
    font-size: 14px;
}
.callout-note {
    background-color: #ddf4ff;
    border-left-color: #0969da;
}
.callout-tip {
    background-color: #dafbe1;
    border-left-color: #1a7f37;
}
.callout-warning {
    background-color: #fff8c5;
    border-left-color: #9a6700;
}
.callout .callout-title {
    font-weight: 600;
    margin-bottom: 4px;
    font-size: 14px;
}
.callout p { margin-bottom: 4px; }
.callout p:last-child { margin-bottom: 0; }

/* Directory listing */
.directory-listing { list-style: none; padding: 0 !important; }
.directory-listing li {
    padding: 5px 8px;
    border-bottom: 1px solid #f0f2f4;
}
.directory-listing li:last-child { border-bottom: none; }
.directory-listing li a {
    display: block;
    text-decoration: none;
    color: #0969da;
}
.directory-listing li a:hover { text-decoration: underline; }
.directory-listing li.dir a { font-weight: 600; color: #1f2328; }
.directory-listing li.dir a::before { content: "📁  "; }
.directory-listing li.file a::before { content: "📄  "; }

/* Footer */
footer {
    margin-top: 32px;
    padding-top: 12px;
    border-top: 1px solid #d1d9e0;
    font-size: 12px;
    color: #656d76;
}
footer a { color: #0969da; text-decoration: none; }
footer a:hover { text-decoration: underline; }

/* Selection highlight */
::selection { background-color: #dbe9f9; }

/* Smooth scroll for anchor links */
html { scroll-behavior: smooth; }

/* Wide mode */
html.wide .container { max-width: 100%; }

/* Dark mode */
html.dark body { background: #0d1117; color: #e6edf3; }
html.dark .breadcrumb { border-color: #30363d; color: #8b949e; }
html.dark .breadcrumb a { color: #58a6ff; }
html.dark .breadcrumb-path span { color: #e6edf3; }
html.dark .toolbar button { border-color: #30363d; color: #8b949e; }
html.dark .toolbar button:hover { background: #21262d; }
html.dark .markdown-body { color: #e6edf3; }
html.dark .markdown-body h1,
html.dark .markdown-body h2,
html.dark .markdown-body h3,
html.dark .markdown-body h4 { color: #e6edf3; }
html.dark .markdown-body h1, html.dark .markdown-body h2 { border-color: #30363d; }
html.dark .markdown-body h5, html.dark .markdown-body h6 { color: #8b949e; }
html.dark .markdown-body a { color: #58a6ff; }
html.dark .markdown-body a:hover { color: #79c0ff; }
html.dark .markdown-body a:visited { color: #bc8cff; }
html.dark .markdown-body .heading-link,
html.dark .markdown-body .heading-link:hover,
html.dark .markdown-body .heading-link:visited { color: inherit; }
html.dark .markdown-body h1[id]:hover::before,
html.dark .markdown-body h2[id]:hover::before,
html.dark .markdown-body h3[id]:hover::before,
html.dark .markdown-body h4[id]:hover::before { color: #58a6ff; }
html.dark .markdown-body code {
    background-color: #161b22; color: #e6edf3; border-color: #30363d;
}
html.dark .markdown-body pre {
    background-color: #161b22; border-color: #30363d;
}
html.dark .markdown-body pre code { background: transparent; }
html.dark .markdown-body blockquote { border-color: #30363d; color: #8b949e; }
html.dark .markdown-body table th { background-color: #161b22; border-color: #30363d; color: #e6edf3; }
html.dark .markdown-body table td { border-color: #30363d; }
html.dark .markdown-body tr:nth-child(2n) { background-color: #161b22; }
html.dark .markdown-body hr { background-color: #30363d; }
html.dark .markdown-body img { opacity: .85; }
html.dark .markdown-body del { color: #8b949e; }
html.dark .markdown-body details { border-color: #30363d; }
html.dark .markdown-body details summary { color: #e6edf3; background: #161b22; }
html.dark .markdown-body .callout { border-color: #30363d; background: #161b22; }
html.dark .markdown-body .callout-title { color: #e6edf3; }
html.dark footer { border-color: #30363d; color: #8b949e; }
html.dark footer a { color: #58a6ff; }
html.dark ::selection { background-color: #1f3a5f; }

/* Responsive */
@media (max-width: 768px) {
    .container { padding: 12px 16px 24px; }
    .markdown-body h1 { font-size: 1.5em; }
    .markdown-body h2 { font-size: 1.25em; }
    .markdown-body pre { font-size: 80%; padding: 10px 12px; }
}

/* Print */
@media print {
    .breadcrumb, footer, .toolbar { display: none; }
    .markdown-body details { border: none; }
    .markdown-body details > summary { display: none; }
    .markdown-body details > * { display: block !important; }
    .markdown-body a { color: inherit; text-decoration: underline; }
    .markdown-body a::after { content: " (" attr(href) ")"; font-size: 80%; color: #666; }
}
"##;
