/*
 * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */

import externals.marked
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.*
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.*

// --------------- Lightweight debug logging ---------------
// Disable debug logging by default
private var SEARCH_DEBUG: Boolean = false
fun dlog(tag: String, msg: String) {
    if (!SEARCH_DEBUG) return
    try {
        console.log("[LYNG][$tag] $msg")
    } catch (_: dynamic) { }
}

@Suppress("unused")
private fun exposeSearchDebugToggle() {
    try {
        val w = window.asDynamic()
        w.setLyngSearchDebug = { enabled: Boolean ->
            SEARCH_DEBUG = enabled
            dlog("debug", "SEARCH_DEBUG=$enabled")
        }
        // Extra runtime helpers to diagnose search at runtime
        w.lyngSearchForceReindex = {
            try {
                dlog("debug", "lyngSearchForceReindex() called")
                searchIndex = null
                searchBuilding = false
                MainScopeProvider.scope.launch {
                    buildSearchIndexOnce()
                    val count = searchIndex?.size ?: -1
                    dlog("search", "forceReindex complete: indexed=$count")
                    searchIndex?.take(5)?.forEachIndexed { i, rec ->
                        dlog("search", "sample[$i]: ${rec.path} | ${rec.title}")
                    }
                }
            } catch (_: dynamic) { }
        }
        w.lyngSearchQuery = { q: String ->
            try {
                dlog("debug", "lyngSearchQuery('$q') called")
                MainScopeProvider.scope.launch {
                    if (searchIndex == null) buildSearchIndexOnce()
                    val idxSize = searchIndex?.size ?: -1
                    val res = performSearch(q)
                    dlog("search", "query '$q' on idx=$idxSize -> ${res.size} hits")
                    res.take(5).forEachIndexed { i, r ->
                        dlog("search", "hit[$i]: score=${scoreQuery(q, r)} path=${r.path} title=${r.title}")
                    }
                }
            } catch (_: dynamic) { }
        }
        dlog("debug", "window.setLyngSearchDebug(Boolean) is available in console")
    } catch (_: dynamic) { }
}

// externals moved to Externals.kt

// Ensure global scroll offset styles and keep a CSS var with the real fixed-top navbar height.
// This guarantees that scrolling to anchors or search hits is not hidden underneath the top bar.
fun ensureScrollOffsetStyles() {
    try {
        val doc = window.document
        if (doc.getElementById("scroll-offset-style") == null) {
            val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement
            style.id = "scroll-offset-style"
            style.textContent = (
                """
                /* Keep a dynamic CSS variable with the measured navbar height */
                :root { --navbar-offset: 56px; }

                /* Make native hash jumps and programmatic scroll account for the fixed header */
                html, body { scroll-padding-top: calc(var(--navbar-offset) + 8px); }

                /* When scrolled into view, keep headings and any id-targeted element below the topbar */
                .markdown-body h1,
                .markdown-body h2,
                .markdown-body h3,
                .markdown-body h4,
                .markdown-body h5,
                .markdown-body h6,
                .markdown-body [id] { scroll-margin-top: calc(var(--navbar-offset) + 8px); }

                /* Also offset search highlights as they can be the initial scroll target */
                mark.search-hit { scroll-margin-top: calc(var(--navbar-offset) + 8px) !important; }
                """
                .trimIndent()
            )
            doc.head?.appendChild(style)
        }
    } catch (_: Throwable) {
        // Best-effort
    }
}

// Measure the current fixed-top navbar height and update the CSS variable
fun updateNavbarOffsetVar(): Int {
    return try {
        val doc = window.document
        val nav = doc.querySelector("nav.navbar.fixed-top") as? HTMLElement
        val px = if (nav != null) kotlin.math.round(nav.getBoundingClientRect().height).toInt() else 0
        doc.documentElement?.let { root ->
            root.asDynamic().style?.setProperty?.invoke(root.asDynamic().style, "--navbar-offset", "${px}px")
        }
        px
    } catch (_: Throwable) { 0 }
}

// Ensure global CSS for search highlights is present (bright, visible everywhere)
fun ensureSearchHighlightStyles() {
    try {
        val doc = window.document
        if (doc.getElementById("search-hit-style") == null) {
            val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement
            style.id = "search-hit-style"
            // Use strong colors and !important to outshine theme/code styles
            style.textContent = (
                """
                mark.search-hit { 
                  background: #ffeb3b !important; /* bright yellow */
                  color: #000 !important;
                  padding: 0 .1em; 
                  border-radius: 2px; 
                  box-shadow: 0 0 0 2px #ffeb3b inset !important;
                }
                code mark.search-hit, pre mark.search-hit {
                  background: #ffd54f !important; /* slightly deeper in code blocks */
                  color: #000 !important;
                  box-shadow: 0 0 0 2px #ffd54f inset !important;
                }
                """
                .trimIndent()
            )
            doc.head?.appendChild(style)
        }
    } catch (_: Throwable) {
        // Best-effort; if styles can't be injected we still proceed
    }
}

// Ensure docs layout tweaks (reduce markdown body top margin to align with TOC)
fun ensureDocsLayoutStyles() {
    try {
        val doc = window.document
        if (doc.getElementById("docs-layout-style") == null) {
            val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement
            style.id = "docs-layout-style"
            style.textContent = (
                """
                /* Align the markdown content top edge with the TOC */
                .markdown-body {
                  margin-top: 0 !important;      /* remove extra outer spacing */
                  padding-top: 0 !important;     /* override GitHub markdown default top padding */
                }
                /* Ensure the first element inside markdown body doesn't add extra space */
                .markdown-body > :first-child {
                  margin-top: 0 !important;
                }
                /* Some markdown renderers give H1 extra top margin; neutralize when first */
                .markdown-body h1:first-child {
                  margin-top: 0 !important;
                }
                .docs-mobile-toc {
                  position: static !important;
                  padding: 0.875rem 1rem;
                  border: 1px solid rgba(128, 128, 128, 0.2);
                  border-radius: 0.75rem;
                  background: var(--bs-body-bg, #fff);
                }
                /* Hide scrollbar for the TOC nav but allow scrolling */
                nav.position-sticky::-webkit-scrollbar {
                  width: 4px;
                }
                .docs-mobile-toc::-webkit-scrollbar {
                  width: 4px;
                }
                nav.position-sticky::-webkit-scrollbar-thumb {
                  background: rgba(128,128,128,0.2);
                  border-radius: 4px;
                }
                .docs-mobile-toc::-webkit-scrollbar-thumb {
                  background: rgba(128,128,128,0.2);
                  border-radius: 4px;
                }
                nav.position-sticky:hover::-webkit-scrollbar-thumb {
                  background: rgba(128,128,128,0.5);
                }
                .docs-mobile-toc:hover::-webkit-scrollbar-thumb {
                  background: rgba(128,128,128,0.5);
                }
                """
                .trimIndent()
            )
            doc.head?.appendChild(style)
        }
    } catch (_: Throwable) {
        // Best-effort
    }
}

// App() moved to App.kt

// DocLink and UnsafeRawHtml moved to Components.kt

fun currentRoute(): String {
    val h = window.location.hash
    // Support both "#/path" and "#path" formats
    val noHash = if (h.startsWith("#")) h.substring(1) else h
    return noHash.removePrefix("/")
}

fun routeToPath(route: String): String {
    val noParams = stripQuery(stripFragment(route))
    return if (noParams.startsWith("docs/")) noParams else "docs/$noParams"
}

// Strip trailing fragment from a route like "docs/file.md#anchor" -> "docs/file.md"
fun stripFragment(route: String): String = route.substringBefore('#')

// Strip query from a route like "docs/file.md?q=term" -> "docs/file.md"
fun stripQuery(route: String): String = route.substringBefore('?')

// Extract lowercase search terms from route query string (?q=...)
fun extractSearchTerms(route: String): List<String> {
    val queryPart = route.substringAfter('?', "")
    if (queryPart.isEmpty()) return emptyList()
    val params = queryPart.split('&')
    val qParam = params.firstOrNull { it.startsWith("q=") }?.substringAfter('=') ?: return emptyList()
    val decoded = try { decodeURIComponent(qParam) } catch (_: dynamic) { qParam }
    return decoded.trim().split(Regex("\\s+"))
        .map { it.lowercase() }
        .filter { it.isNotEmpty() }
}

// Highlight words in root that start with any of the terms (case-insensitive). Returns count of hits.
fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
    // Make sure CSS for highlighting is injected
    ensureSearchHighlightStyles()
    // Always remove previous highlights first so calling with empty terms clears them
    try {
        val prev = root.getElementsByClassName("search-hit")
        // Because HTMLCollection is live, copy to array first
        val arr = (0 until prev.length).mapNotNull { prev.item(it) as? HTMLElement }.toList()
        arr.forEach { mark ->
            val parent = mark.parentNode
            val textNode = root.ownerDocument!!.createTextNode(mark.textContent ?: "")
            parent?.replaceChild(textNode, mark)
        }
    } catch (_: Throwable) {}

    if (terms.isEmpty()) return 0

    // Allow highlighting even inside CODE and PRE per request; still skip scripts, styles, and keyboard samples
    val skipTags = setOf("SCRIPT", "STYLE", "KBD", "SAMP")
    var hits = 0

    fun processNode(node: org.w3c.dom.Node) {
        when (node.nodeType) {
            org.w3c.dom.Node.ELEMENT_NODE -> {
                val el = node.unsafeCast<HTMLElement>()
                if (skipTags.contains(el.tagName)) return
                if (el.classList.contains("no-search")) return
                // copy list as array, because modifying tree during iteration
                val children = mutableListOf<org.w3c.dom.Node>()
                val cn = el.childNodes
                for (i in 0 until cn.length) children.add(cn.item(i)!!)
                children.forEach { processNode(it) }
            }
            org.w3c.dom.Node.TEXT_NODE -> {
                val text = node.nodeValue ?: return
                if (text.isBlank()) return
                // Tokenize by words and rebuild
                val parent = node.parentNode ?: return
                val doc = root.ownerDocument!!
                val container = doc.createDocumentFragment()
                var pos = 0
                val wordRegex = Regex("[A-Za-z0-9_]+")
                for (m in wordRegex.findAll(text)) {
                    val start = m.range.first
                    val end = m.range.last + 1
                    if (start > pos) container.appendChild(doc.createTextNode(text.substring(pos, start)))
                    val token = m.value
                    val tokenLower = token.lowercase()
                    // choose the longest term that is a prefix of the token
                    var bestLen = 0
                    if (terms.isNotEmpty()) {
                        for (t in terms) {
                            if (t.isNotEmpty() && tokenLower.startsWith(t) && t.length > bestLen) bestLen = t.length
                        }
                    }
                    if (bestLen > 0 && bestLen <= token.length) {
                        val prefix = token.substring(0, bestLen)
                        val suffix = token.substring(bestLen)
                        val mark = doc.createElement("mark") as HTMLElement
                        mark.className = "search-hit"
                        mark.textContent = prefix
                        container.appendChild(mark)
                        if (suffix.isNotEmpty()) container.appendChild(doc.createTextNode(suffix))
                        hits++
                    } else {
                        container.appendChild(doc.createTextNode(token))
                    }
                    pos = end
                }
                if (pos < text.length) container.appendChild(doc.createTextNode(text.substring(pos)))
                parent.replaceChild(container, node)
            }
        }
    }
    processNode(root)
    return hits
}

fun renderMarkdown(src: String): String =
    net.sergeych.lyngweb.highlightLyngHtml(
        net.sergeych.lyngweb.ensureBootstrapCodeBlocks(
            ensureBootstrapTables(
                ensureDefinitionLists(
                    marked.parse(src)
                )
            )
        )
    )

// Suspend variant that uses the AST-backed highlighter for Lyng code blocks.
suspend fun renderMarkdownAsync(src: String): String =
    net.sergeych.lyngweb.highlightLyngHtmlAsync(
        net.sergeych.lyngweb.ensureBootstrapCodeBlocks(
            ensureBootstrapTables(
                ensureDefinitionLists(
                    marked.parse(src)
                )
            )
        )
    )

// Pure function to render the Reference list HTML from a list of doc paths.
// Returns a Bootstrap-styled <ul> list with links to the docs routes.
fun renderReferenceListHtml(docs: List<String>): String {
    if (docs.isEmpty()) return "<p>No documents found.</p>"
    val items = docs.sorted().joinToString(separator = "") { path ->
        val name = path.substringAfterLast('/')
        val dir = path.substringBeforeLast('/', "")
        buildString {
            append("<li class=\"list-group-item d-flex justify-content-between align-items-center\">")
            append("<div>")
            append("<a href=\"#/$path\" class=\"link-body-emphasis text-decoration-none\">")
            append(name)
            append("</a>")
            if (dir.isNotEmpty()) {
                append("<br><small class=\"text-muted\">")
                append(dir)
                append("</small>")
            }
            append("</div>")
            append("<i class=\"bi bi-chevron-right\"></i>")
            append("</li>")
        }
    }
    return "<ul class=\"list-group\">$items</ul>"
}

// PageTemplate moved to Components.kt

// DocsPage moved to Pages.kt

fun extractTitleFromMarkdown(md: String): String? {
    val lines = md.lines()
    val h1 = lines.firstOrNull { it.trimStart().startsWith("# ") }
    return h1?.trimStart()?.removePrefix("# ")?.trim()
}

suspend fun initDocsDropdown() {
    try {
        dlog("docs-dd", "initDocsDropdown start")
        val menu = document.getElementById("docsDropdownMenu") ?: run {
            dlog("docs-dd", "#docsDropdownMenu not found")
            return
        }
        // Fetch docs index
        val resp = window.fetch("docs-index.json").await()
        if (!resp.ok) {
            dlog("docs-dd", "docs-index.json fetch failed: status=${resp.status}")
            return
        }
        val text = resp.text().await()
        val arr = JSON.parse(text) as Array<String>
        val all = arr.toList().sorted()
        dlog("docs-dd", "index entries=${all.size}")
        // Filter excluded by reading each markdown and looking for the marker, and collect titles
        val filtered = mutableListOf<String>()
        val titles = mutableMapOf<String, String>()
        var excluded = 0
        var failed = 0
        for (path in all) {
            try {
                val url = "./" + encodeURI(path)
                val r = window.fetch(url).await()
                if (!r.ok) {
                    failed++
                    dlog("docs-dd", "fetch fail ${r.status} for $url")
                    continue
                }
                val body = r.text().await()
                if (!body.contains("[//]: # (excludeFromIndex)") &&
                    body.contains("[//]: # (topMenu)") ) {
                    filtered.add(path)
                    // Reuse shared title extractor: first H1 or fallback to file name
                    val title = extractTitleFromMarkdown(body) ?: path.substringAfterLast('/')
                    titles[path] = title
                } else excluded++
            } catch (t: Throwable) {
                failed++
                dlog("docs-dd", "exception fetching $path : ${t.message}")
            }
        }
        dlog("docs-dd", "filtered=${filtered.size} excluded=$excluded failed=$failed")
        // Sort entries by display name (file name) case-insensitively
        val sortedFiltered = filtered.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.substringAfterLast('/') })
        // Build items after the static first items (tutorial, optional static entries, divider)
        var appended = 0
        sortedFiltered.forEach { path ->
            val name = titles[path] ?: path.substringAfterLast('/')
            val li = document.createElement("li")
            val a = document.createElement("a") as HTMLAnchorElement
            a.className = "dropdown-item"
            a.href = "#/$path"
            a.setAttribute("data-route", "docs")
            a.textContent = name
            // Ensure SPA navigation and close navbar collapse on small screens
            a.onclick = { ev ->
                ev.preventDefault()
                dlog("nav", "docs dropdown -> navigate #/$path")
                window.location.hash = "#/$path"
                closeNavbarCollapse()
            }
            li.appendChild(a)
            menu.appendChild(li)
            appended++
        }
        dlog("docs-dd", "appended=$appended docs to dropdown")
    } catch (_: Throwable) {
        dlog("docs-dd", "exception during initDocsDropdown")
    }
}

private fun closeNavbarCollapse() {
    val collapse = document.getElementById("topbarNav") as? HTMLElement
    collapse?.classList?.remove("show")
    // update toggler aria-expanded if present
    val togglers = document.getElementsByClassName("navbar-toggler")
    if (togglers.length > 0) {
        val t = togglers.item(0) as? HTMLElement
        t?.setAttribute("aria-expanded", "false")
    }
}

// ---------------- Site-wide search (client-side) ----------------

internal data class DocRecord(val path: String, val title: String, val text: String)

private var searchIndex: List<DocRecord>? = null
private var searchBuilding = false
private var searchInitDone = false

// ---- Search history (last 7 entries) ----
private const val SEARCH_HISTORY_KEY = "lyng.search.history"

private fun loadSearchHistory(): MutableList<String> {
    return try {
        val raw = window.localStorage.getItem(SEARCH_HISTORY_KEY) ?: return mutableListOf()
        // Stored as newline-separated values to avoid JSON pitfalls across browsers
        raw.split('\n').mapNotNull { it.trim() }.filter { it.isNotEmpty() }.toMutableList()
    } catch (_: Throwable) {
        mutableListOf()
    }
}

private fun saveSearchHistory(list: List<String>) {
    try {
        window.localStorage.setItem(SEARCH_HISTORY_KEY, list.joinToString("\n"))
    } catch (_: Throwable) { }
}

internal fun rememberSearchQuery(q: String) {
    val query = q.trim()
    if (query.isBlank()) return
    val list = loadSearchHistory()
    list.removeAll { it.equals(query, ignoreCase = true) }
    list.add(0, query)
    while (list.size > 7) list.removeAt(list.lastIndex)
    saveSearchHistory(list)
}

internal fun norm(s: String): String = s.lowercase()
    .replace("`", " ")
    .replace("*", " ")
    .replace("#", " ")
    .replace("[", " ")
    .replace("]", " ")
    .replace("(", " ")
    .replace(")", " ")
    .replace(Regex("\\n+"), " ")
    .replace(Regex("\\s+"), " ").trim()

internal fun plainFromMarkdown(md: String): String {
    // Construct Regex instances at call time inside try/catch to avoid module init crashes
    // in browsers that are strict about Unicode RegExp parsing (Safari/Chrome).
    // Use non-greedy dot-all equivalents ("[\n\r\s\S]") instead of character classes with ']' where possible.
    try {
        // Safer patterns (avoid unescaped ']' inside character classes):
        val reBacktickCodeBlocks = Regex("```[\\s\\S]*?```")
        val reTildeCodeBlocks = Regex("~~~[\\s\\S]*?~~~")
        val reInlineCode = Regex("`([^`]*)`")
        val reBlockquote = Regex("^> +", setOf(RegexOption.MULTILINE))
        val reHeadings = Regex("^#+ +", setOf(RegexOption.MULTILINE))
        // Images: ![alt](url) — capture alt lazily with [\s\S]*? to avoid character class pitfalls
        val reImage = Regex("!\\[([\\s\\S]*?)]\\([^)]*\\)")
        // Links: [text](url) — same approach, keep the link text in group 1
        val reLink = Regex("\\[([\\s\\S]*?)]\\([^)]*\\)")

        var t = md
        // Triple-backtick code blocks across lines
        t = t.replace(reBacktickCodeBlocks, " ")
        t = t.replace(reTildeCodeBlocks, " ")
        // Keep inline code content so API names in prose and headings remain searchable
        t = t.replace(reInlineCode, "\$1")
        // Strip blockquotes and headings markers
        t = t.replace(reBlockquote, "")
        t = t.replace(reHeadings, "")
        // Images and links
        t = t.replace(reImage, " ")
        // Keep link text (group 1). Kotlin string needs "$" escaped once to pass "$1"
        t = t.replace(reLink, "\$1")
        return norm(t)
    } catch (e: Throwable) {
        dlog("search", "plainFromMarkdown error: ${e.message}")
        // Minimal safe fallback: strip code blocks and inline code, then normalize
        var t = md
        t = t.replace(Regex("```[\\s\\S]*?```"), " ")
        t = t.replace(Regex("~~~[\\s\\S]*?~~~"), " ")
        t = t.replace(Regex("`([^`]*)`"), "\$1")
        return norm(t)
    }
}

private suspend fun buildSearchIndexOnce() {
    if (searchIndex != null || searchBuilding) return
    searchBuilding = true
    dlog("search", "buildSearchIndexOnce: start")
    try {
        val resp = window.fetch("docs-index.json").await()
        if (!resp.ok) {
            dlog("search", "docs-index.json fetch failed: status=${resp.status}")
            return
        }
        val text = resp.text().await()
        val arr = JSON.parse(text) as Array<String>
        val all = arr.toList().sorted()
        dlog("search", "docs-index entries=${all.size}")
        val list = mutableListOf<DocRecord>()
        var excluded = 0
        var failed = 0
        var loggedFailures = 0
        for (path in all) {
            try {
                // Always fetch via a relative URL from site root and encode non-ASCII safely
                val url = "./" + encodeURI(path)
                val r = window.fetch(url).await()
                if (!r.ok) {
                    failed++
                    if (loggedFailures < 3) {
                        dlog("search", "fetch fail ${r.status} for $url")
                        loggedFailures++
                    }
                    continue
                }
                val body = r.text().await()
                if (body.contains("[//]: # (excludeFromIndex)")) { excluded++; continue }
                val title = extractTitleFromMarkdown(body) ?: path.substringAfterLast('/')
                val plain = plainFromMarkdown(body)
                list += DocRecord(path = path, title = title, text = plain)
            } catch (t: Throwable) {
                failed++
                if (loggedFailures < 3) {
                    dlog("search", "exception processing $path : ${t.message}")
                    loggedFailures++
                }
            }
        }
        searchIndex = list
        dlog("search", "buildSearchIndexOnce: done, indexed=${list.size} excluded=$excluded failed=$failed")
        list.take(5).forEachIndexed { i, rec ->
            dlog("search", "indexed[$i]: ${rec.path} | ${rec.title} (len=${rec.text.length})")
        }
    } catch (_: Throwable) {
        dlog("search", "buildSearchIndexOnce: exception")
    } finally {
        searchBuilding = false
    }
}

private fun scoreQuery(q: String, rec: DocRecord): Int {
    val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() }
    if (terms.isEmpty()) return 0
    var score = 0
    val title = norm(rec.title)
    val text = rec.text
    // Title bonuses: longer prefix matches get higher score
    for (t in terms) {
        if (title.startsWith(t)) score += 120 + t.length
        else if (title.split(Regex("[A-Za-z0-9_]+")).isEmpty()) { /* no-op */ }
        else {
            // title words prefix
            val wr = Regex("[A-Za-z0-9_]+")
            if (wr.findAll(title).any { it.value.startsWith(t) }) score += 70 + t.length
        }
    }
    // Body: count how many tokens start with any term, weight by term length (cap to avoid giant docs dominating)
    run {
        val wr = Regex("[A-Za-z0-9_]+")
        var matches = 0
        for (m in wr.findAll(text)) {
            val token = m.value
            val tl = token.lowercase()
            var best = 0
            for (t in terms) if (tl.startsWith(t) && t.length > best) best = t.length
            if (best > 0) {
                matches++
                score += 2 * best
                if (matches >= 50) break // cap
            }
        }
    }
    // Prefer shorter files a bit
    score += (200 - kotlin.math.min(200, text.length / 500))
    return score
}

private fun renderSearchHistoryDropdown(menu: HTMLDivElement) {
    val hist = loadSearchHistory()
    if (hist.isEmpty()) {
        menu.innerHTML = "<div class=\"dropdown-item disabled\">No recent searches</div>"
    } else {
        val items = buildString {
            hist.take(7).forEach { hq ->
                val safe = hq.replace("<", "&lt;").replace(">", "&gt;")
                append("<a href=\"#/search?q=" + encodeURIComponent(hq) + "\" class=\"dropdown-item\" data-q=\"")
                append(safe)
                append("\">")
                append(safe)
                append("</a>")
            }
        }
        menu.innerHTML = items
    }
    menu.classList.add("show")
}

private fun hideSearchResults(menu: HTMLDivElement) {
    dlog("search-ui", "hideSearchResults")
    menu.classList.remove("show")
    menu.innerHTML = ""
}

private suspend fun performSearch(q: String): List<DocRecord> {
    if (searchIndex == null) buildSearchIndexOnce()
    val idx = searchIndex ?: run {
        dlog("search", "performSearch: index is null after build attempt")
        return emptyList()
    }
    if (q.isBlank()) return emptyList()
    val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() }
    if (terms.isEmpty()) return emptyList()
    val wordRegex = Regex("[A-Za-z0-9_]+")
    fun hasPrefixWord(s: String): Boolean {
        for (m in wordRegex.findAll(s)) {
            val tl = m.value.lowercase()
            for (t in terms) if (tl.startsWith(t)) return true
        }
        return false
    }
    val res = idx.filter { rec ->
        hasPrefixWord(norm(rec.title)) || hasPrefixWord(rec.text)
    }
    dlog("search", "performSearch: q='$q' idx=${idx.size} -> ${res.size} results")
    return res
}

private fun debounce(scope: CoroutineScope, delayMs: Long, block: suspend () -> Unit): () -> Unit {
    var job: Job? = null
    return {
        job?.cancel()
        job = scope.launch {
            delay(delayMs)
            block()
        }
    }
}

fun initTopSearch(attempt: Int = 0) {
    if (searchInitDone) return
    val input = document.getElementById("topSearch") as? HTMLInputElement
    val menu = document.getElementById("topSearchMenu") as? HTMLDivElement
    if (input == null || menu == null) {
        // Retry a few times in case DOM is not fully ready yet
        if (attempt < 10) {
            dlog("init", "initTopSearch: missing nodes (input=$input, menu=$menu) retry $attempt")
            window.setTimeout({ initTopSearch(attempt + 1) }, 50)
        }
        return
    }
    dlog("init", "initTopSearch: wiring handlers")
    val scope = MainScopeProvider.scope

    // Debounced dropdown history refresher
    val runSearch = debounce(scope, 120L) {
        renderSearchHistoryDropdown(menu)
    }

    // Keep the input focused when interacting with the dropdown so it doesn't blur/close
    menu.onmousedown = { ev ->
        ev.preventDefault()
        input.focus()
    }

    input.oninput = {
        dlog("event", "search oninput value='${input.value}'")
        runSearch()
    }
    input.onfocus = {
        dlog("event", "search onfocus: show history")
        renderSearchHistoryDropdown(menu)
    }
    input.onkeydown = { ev ->
        val key = ev.asDynamic().key as String
        dlog("event", "search onkeydown key='$key'")
        when (ev.asDynamic().key as String) {
            "Escape" -> {
                hideSearchResults(menu)
            }
            "Enter" -> {
                val q = input.value.trim()
                if (q.isNotBlank()) {
                    rememberSearchQuery(q)
                    val url = "#/search?q=" + encodeURIComponent(q)
                    dlog("nav", "Enter -> navigate $url")
                    window.location.hash = url
                    hideSearchResults(menu)
                    closeNavbarCollapse()
                }
            }
    }
    }
    // Hide on blur after a short delay to allow click
    input.onblur = {
        dlog("event", "search onblur -> hide after delay")
        window.setTimeout({ hideSearchResults(menu) }, 150)
    }
    searchInitDone = true
    dlog("init", "initTopSearch: done")
}

// Provide a global coroutine scope for utilities without introducing a framework
private object MainScopeProvider {
    val scope: CoroutineScope by lazy { kotlinx.coroutines.MainScope() }
}

// ReferencePage moved to ReferencePage.kt
// HomePage moved to HomePage.kt

// ---- Theme handling: follow system theme automatically ----

private fun applyTheme(isDark: Boolean) {
    // Toggle Bootstrap theme attribute
    document.body?.setAttribute("data-bs-theme", if (isDark) "dark" else "light")
    // Toggle GitHub Markdown CSS light/dark
    val light = document.getElementById("md-light") as? HTMLLinkElement
    val dark = document.getElementById("md-dark") as? HTMLLinkElement
    // Toggle highlight.js CSS for Kotlin fenced blocks
    val hljsLight = document.getElementById("hljs-light") as? HTMLLinkElement
    val hljsDark = document.getElementById("hljs-dark") as? HTMLLinkElement
    if (isDark) {
        light?.setAttribute("disabled", "")
        dark?.removeAttribute("disabled")
        hljsLight?.setAttribute("disabled", "")
        hljsDark?.removeAttribute("disabled")
    } else {
        dark?.setAttribute("disabled", "")
        light?.removeAttribute("disabled")
        hljsDark?.setAttribute("disabled", "")
        hljsLight?.removeAttribute("disabled")
    }
}

private fun initAutoTheme() {
    val mql = try { window.matchMedia("(prefers-color-scheme: dark)") } catch (_: Throwable) { null }
    if (mql == null) {
        applyTheme(false)
        return
    }
    // Set initial
    applyTheme(mql.matches)
    // React to changes (modern browsers)
    try {
        mql.addEventListener("change", { ev ->
            val isDark = try { (ev.asDynamic().matches as Boolean) } catch (_: Throwable) { mql.matches }
            applyTheme(isDark)
        })
    } catch (_: Throwable) {
        // Legacy API fallback
        try {
            (mql.asDynamic()).addListener { mq: dynamic ->
                val isDark = try { mq.matches as Boolean } catch (_: Throwable) { false }
                applyTheme(isDark)
            }
        } catch (_: Throwable) {}
    }
}

// Convert pseudo Markdown definition lists rendered by marked as paragraphs into proper <dl><dt><dd> structures.
// Pattern supported (common in many MD flavors):
// Term\n
// : Definition paragraph 1\n
// : Definition paragraph 2 ...
// After marked parses it, it becomes:
// <p>Term</p>\n<p>: Definition paragraph 1</p>\n<p>: Definition paragraph 2</p>
// We transform such consecutive blocks into:
// <dl><dt>Term</dt><dd>Definition paragraph 1</dd><dd>Definition paragraph 2</dd></dl>
private fun ensureDefinitionLists(html: String): String {
    // We operate per <p> block, and if its inner HTML contains newline-separated lines
    // in the form:
    //   Term\n: Def1\n: Def2
    // we convert this <p> into a <dl>...</dl>
    val pBlock = Regex("""<p>([\s\S]*?)</p>""", setOf(RegexOption.IGNORE_CASE))

    return pBlock.replace(html) { match ->
        val inner = match.groupValues[1]
        val lines = inner.split(Regex("\r?\n"))
        if (lines.isEmpty()) return@replace match.value

        val term = lines.first().trim()
        if (term.startsWith(":")) return@replace match.value // cannot start with ':'

        val defs = lines.drop(1)
            .map { it.trim() }
            .filter { it.startsWith(":") }
            .map { s ->
                // remove leading ':' and optional single leading space
                val t = s.removePrefix(":")
                if (t.startsWith(' ')) t.substring(1) else t
            }

        if (defs.isEmpty()) return@replace match.value

        buildString {
            append("<dl><dt>")
            append(term)
            append("</dt>")
            defs.forEach { d ->
                append("<dd>")
                append(d)
                append("</dd>")
            }
            append("</dl>")
        }
    }
}

// Ensure all markdown-rendered tables use Bootstrap styling
private fun ensureBootstrapTables(html: String): String {
    // Match <table ...> opening tags (case-insensitive)
    val tableTagRegex = Regex("<table(\\s+[^>]*)?>", RegexOption.IGNORE_CASE)
    val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)

    return tableTagRegex.replace(html) { match ->
        val attrs = match.groups[1]?.value ?: ""
        if (attrs.isBlank()) return@replace "<table class=\"table\">"

        // If class attribute exists, append 'table' if not already present
        var newAttrs = attrs
        val m = classAttrRegex.find(attrs)
        if (m != null) {
            val quote = m.groupValues[1]
            val classes = m.groupValues[2]
            val hasTable = classes.split("\\s+".toRegex()).any { it.equals("table", ignoreCase = false) }
            if (!hasTable) {
                val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "table" else "$it table" }) + quote
                newAttrs = attrs.replaceRange(m.range, updated)
            }
        } else {
            // No class attribute, insert one at the beginning
            newAttrs = " class=\"table\"" + attrs
        }
        "<table$newAttrs>"
    }
}

// Ensure all markdown-rendered code blocks (<pre>...</pre>) have Bootstrap-like `.code` class
fun ensureBootstrapCodeBlocks(html: String): String {
    // Target opening <pre ...> tags (case-insensitive)
    val preTagRegex = Regex("<pre(\\s+[^>]*)?>", RegexOption.IGNORE_CASE)
    val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)

    return preTagRegex.replace(html) { match ->
        val attrs = match.groups[1]?.value ?: ""
        if (attrs.isBlank()) return@replace "<pre class=\"code\">"

        var newAttrs = attrs
        val m = classAttrRegex.find(attrs)
        if (m != null) {
            val quote = m.groupValues[1]
            val classes = m.groupValues[2]
            val hasCode = classes.split("\\s+".toRegex()).any { it.equals("code", ignoreCase = false) }
            if (!hasCode) {
                val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
                newAttrs = attrs.replaceRange(m.range, updated)
            }
        } else {
            // No class attribute, insert one at the beginning
            newAttrs = " class=\"code\"" + attrs
        }
        "<pre$newAttrs>"
    }
}

// moved highlighting utilities to :lyngweb

fun rewriteImages(root: HTMLElement, basePath: String) {
    val imgs = root.querySelectorAll("img")
    for (i in 0 until imgs.length) {
        val el = imgs.item(i) as? HTMLImageElement ?: continue
        val src = el.getAttribute("src") ?: continue
        if (src.startsWith("http") || src.startsWith("/") || src.startsWith("#")) continue
        el.setAttribute("src", normalizePath("$basePath/$src"))
    }
}

fun rewriteAnchors(
    root: HTMLElement,
    basePath: String,
    currentDocPath: String,
    navigate: (String) -> Unit
) {
    val asEl = root.querySelectorAll("a")
    for (i in 0 until asEl.length) {
        val a = asEl.item(i) as? HTMLAnchorElement ?: continue
        // Skip the inline Docs back button we inject before the first H1
        if (a.classList.contains("doc-back-btn") || a.getAttribute("data-no-spa") == "true") continue
        val href = a.getAttribute("href") ?: continue
        // Skip external and already-SPA hashes
        if (
            href.startsWith("http:") || href.startsWith("https:") ||
            href.startsWith("mailto:") || href.startsWith("tel:") ||
            href.startsWith("javascript:") || href.startsWith("/") ||
            href.startsWith("#/")
        ) continue
        if (href.startsWith("#")) {
            // Intra-page link: convert to SPA hash including current document route
            val frag = href.removePrefix("#")
            val route = "$currentDocPath#$frag"
            a.setAttribute("href", "#/$route")
            a.onclick = { ev ->
                ev.preventDefault()
                navigate(route)
            }
            continue
        }
        if (href.contains(".md") || href.contains(".lyng")) {
            val parts = href.split('#', limit = 2)
            val rawPath = parts[0]
            val frag = if (parts.size > 1) parts[1] else null
            val mdPath = when {
                rawPath.endsWith(".lyng") -> "$rawPath.md"
                else -> rawPath
            }
            val target = if (mdPath.startsWith("docs/")) {
                normalizePath(mdPath)
            } else {
                normalizePath("$basePath/$mdPath")
            }
            val route = if (frag.isNullOrBlank()) {
                target
            } else {
                "$target#$frag"
            }
            a.setAttribute("href", "#/$route")
            a.onclick = { ev ->
                ev.preventDefault()
                navigate(route)
            }
        } else {
            // Non-md relative link: make it relative to the md file location
            a.setAttribute("href", normalizePath("$basePath/$href"))
        }
    }
}

private data class TocTreeNode(
    val headingLevel: Int,
    val id: String,
    val title: String,
    val children: MutableList<TocTreeNode> = mutableListOf(),
)

fun buildToc(root: HTMLElement): List<TocItem> {
    val used = hashSetOf<String>()
    val tocRoot = TocTreeNode(headingLevel = 0, id = "", title = "")
    val stack = mutableListOf(tocRoot)
    val hs = root.querySelectorAll("h1, h2, h3, h4, h5, h6")
    for (i in 0 until hs.length) {
        val h = hs.item(i) as? HTMLHeadingElement ?: continue
        val level = h.tagName.removePrefix("H").toIntOrNull() ?: continue
        var id = h.id.ifBlank { slugify(h.textContent ?: "") }
        if (id.isBlank()) id = "section-${i + 1}"
        var unique = id
        var n = 2
        while (!used.add(unique)) {
            unique = "$id-$n"
            n++
        }
        h.id = unique

        while (stack.last().headingLevel >= level) stack.removeAt(stack.lastIndex)
        val node = TocTreeNode(level, unique, h.textContent ?: "")
        stack.last().children += node
        stack += node
    }

    val out = mutableListOf<TocItem>()
    fun visit(node: TocTreeNode, normalizedLevel: Int) {
        if (node.id.isNotEmpty()) out += TocItem(normalizedLevel, node.id, node.title)
        node.children.forEach { child -> visit(child, normalizedLevel + 1) }
    }
    tocRoot.children.forEach { child -> visit(child, 1) }
    return out
}

internal fun slugify(s: String): String = s.lowercase()
    .replace("[^a-z0-9 _-]".toRegex(), "")
    .trim()
    .replace("[\n\r\t ]+".toRegex(), "-")

internal fun normalizePath(path: String): String {
    val parts = mutableListOf<String>()
    val raw = path.split('/')
    for (p in raw) {
        when (p) {
            "", "." -> {}
            ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.size - 1)
            else -> parts += p
        }
    }
    return parts.joinToString("/")
}

// ---- Scrollspy helpers ----
// Given a list of heading top positions relative to viewport (in px),
// returns the index of the active section using an offset. The active section
// is the last heading whose top is above or at the offset line.
// If none are above the offset, returns 0. If list is empty, returns 0.
fun activeIndexForTops(tops: List<Double>, offsetPx: Double): Int {
    if (tops.isEmpty()) return 0
    var lastAbove = 0
    for (i in tops.indices) {
        if (tops[i] <= offsetPx) {
            lastAbove = i
        } else {
            break
        }
    }
    return lastAbove
}

fun main() {
    // Initialize automatic system theme before rendering UI
    initAutoTheme()
    renderComposable(rootElementId = "root") { App() }
}

// Extract anchor fragment from a window location hash of the form
// "#/docs/path.md#anchor" -> "anchor"; returns null if none
fun anchorFromHash(hash: String): String? {
    if (!hash.startsWith("#/")) return null
    val idx = hash.indexOf('#', startIndex = 2) // look for second '#'
    return if (idx >= 0 && idx + 1 < hash.length) hash.substring(idx + 1) else null
}
