// TocScrollSpy — small client-side island that highlights the active
// TOC link as the user scrolls past the corresponding heading.
//
// The DocsLayout renders a static TOC; this island runs in the
// browser, watches the current h2/h3 headings in the page content,
// and toggles `data-active` on the matching `[data-toc-link]`.
// Pure side-effect — no UI of its own.
//
// Why this lives as a separate primitive: the DocsLayout itself
// stays SSR-safe and stateless; the scroll spy needs window APIs.
// Apps render this once near the top of their page tree (inside the
// layout, but conditionally — only mount when there IS a TOC).

import { useEffect, type ReactNode } from 'react'

interface TocScrollSpyProps {
  /** CSS selector for the content container holding the headings.
   *  Defaults to `main` since the layout's content lives there. */
  readonly contentSelector?: string
}

export const TocScrollSpy = ({ contentSelector = 'main' }: TocScrollSpyProps): ReactNode => {
  useEffect(() => {
    const main = document.querySelector<HTMLElement>(contentSelector)
    if (!main) return

    let headings: HTMLElement[] = []
    let frame: number | null = null

    let active: string | null = null
    const tocLinks = (): HTMLElement[] =>
      Array.from(document.querySelectorAll<HTMLElement>('[data-toc-link]'))

    const tocLinkFor = (id: string): HTMLElement | null =>
      tocLinks().find((link) => link.dataset.tocLink === id) ?? null

    const setActive = (id: string | null): void => {
      if (id === active) return

      for (const prev of tocLinks()) {
        prev?.removeAttribute('data-active')
        prev?.removeAttribute('aria-current')
      }

      active = id
      if (id) {
        const next = tocLinkFor(id)
        next?.setAttribute('data-active', 'true')
        next?.setAttribute('aria-current', 'location')
      }
    }

    const updateActive = (): void => {
      frame = null
      if (headings.length === 0) {
        setActive(null)
        return
      }

      // A stable reading line just below the sticky header. Pick the
      // last heading that has crossed it; before the first heading, keep
      // the first TOC item active so the sidebar never looks inert.
      const readingLine = Math.min(180, Math.max(96, window.innerHeight * 0.24))
      let current = headings[0]?.id ?? null

      for (const heading of headings) {
        if (heading.getBoundingClientRect().top <= readingLine) {
          current = heading.id
        } else {
          break
        }
      }

      const scrollBottom = window.scrollY + window.innerHeight
      const docBottom = document.documentElement.scrollHeight
      if (docBottom - scrollBottom < 2) current = headings[headings.length - 1]?.id ?? current

      setActive(current)
    }

    const scheduleUpdate = (): void => {
      if (frame !== null) return
      frame = window.requestAnimationFrame(updateActive)
    }

    const collectHeadings = (): void => {
      headings = Array.from(main.querySelectorAll<HTMLElement>('h2[id], h3[id]'))
      active = null
      scheduleUpdate()
    }

    collectHeadings()

    const observer = new MutationObserver(collectHeadings)
    observer.observe(main, { childList: true, subtree: true })
    window.addEventListener('scroll', scheduleUpdate, { passive: true })
    window.addEventListener('resize', scheduleUpdate)
    window.addEventListener('hashchange', scheduleUpdate)

    return () => {
      observer.disconnect()
      window.removeEventListener('scroll', scheduleUpdate)
      window.removeEventListener('resize', scheduleUpdate)
      window.removeEventListener('hashchange', scheduleUpdate)
      if (frame !== null) window.cancelAnimationFrame(frame)
    }
  }, [contentSelector])

  return null
}
