import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { Editor } from "@tiptap/core";

const SCROLL_OFFSET_PX = 100;

interface TocItem {
  id: string;
  text: string;
  level: number;
  pos: number;
}

interface TocNode extends TocItem {
  children: TocNode[];
}

function extractHeadings(editor: Editor): TocItem[] {
  const items: TocItem[] = [];
  editor.state.doc.descendants((node, pos) => {
    if (node.type.name === "heading") {
      const level = node.attrs.level as number;
      const text = node.textContent;
      if (text.trim()) {
        items.push({ id: `heading-${pos}`, text, level, pos });
      }
    }
  });
  return items;
}

function buildTree(headings: TocItem[]): TocNode[] {
  const root: TocNode[] = [];
  const stack: TocNode[] = [];

  for (const h of headings) {
    const node: TocNode = { ...h, children: [] };

    while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
      stack.pop();
    }

    if (stack.length === 0) {
      root.push(node);
    } else {
      stack[stack.length - 1].children.push(node);
    }

    stack.push(node);
  }

  return root;
}

/**
 * Resolve ProseMirror position to the closest heading DOM element.
 */
function resolveHeadingElement(editor: Editor, pos: number): HTMLElement | null {
  try {
    const dom = editor.view.domAtPos(pos + 1);
    let el: HTMLElement | null =
      dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
    while (el && !/^H[1-6]$/i.test(el.tagName)) {
      el = el.parentElement;
    }
    return el;
  } catch {
    return null;
  }
}

interface TableOfContentsProps {
  editor: Editor | null;
  scrollContainer?: HTMLElement | null;
  autoCollapse?: boolean;
  onNavigate?: () => void;
}

export function TableOfContents({ editor, scrollContainer, autoCollapse = false, onNavigate }: TableOfContentsProps) {
  const [headings, setHeadings] = useState<TocItem[]>([]);
  const [activePos, setActivePos] = useState<number | null>(null);

  const debounceRef = useRef(0);

  useEffect(() => {
    if (!editor) return;
    setHeadings(extractHeadings(editor));
    // 60ms debounce: short enough that headings typed by any collaborator
    // (local or remote via Yjs) appear in the TOC with no perceptible lag
    // while still coalescing bursts of per-keystroke updates.
    const debouncedUpdate = () => {
      clearTimeout(debounceRef.current);
      debounceRef.current = window.setTimeout(
        () => setHeadings(extractHeadings(editor)),
        60,
      );
    };
    editor.on("update", debouncedUpdate);
    // Yjs remote transactions also fire "transaction" - listen to both so
    // the TOC keeps pace with collaborators editing in other browsers.
    editor.on("transaction", debouncedUpdate);
    return () => {
      editor.off("update", debouncedUpdate);
      editor.off("transaction", debouncedUpdate);
      clearTimeout(debounceRef.current);
    };
  }, [editor]);

  // Scroll-spy via IntersectionObserver - no per-frame scroll work.
  //
  // Semantics: active heading = the *topmost visible heading inside the scroll
  // container's reading zone* (below the SCROLL_OFFSET_PX top margin).
  // This is intentionally different from the publisher-side TOC which uses a
  // "last heading scrolled past the offset line" rule (see
  // `backend/src/publisher/html-renderer.ts`). Rationale:
  //  - Editor: users navigate non-linearly (click TOC, jump, split view). The
  //    topmost-visible rule reacts instantly when a heading enters the
  //    reading zone, so the sidebar always reflects what the user is looking
  //    at.
  //  - Publisher: readers scroll linearly through the article. The
  //    last-scrolled-past rule matches classic docs UX (MDN, Docusaurus) and
  //    keeps the active entry stable while reading body text between
  //    headings.
  // Both use IntersectionObserver as the primitive; only the selection logic
  // differs. Do not "unify" the two without considering the UX implications.
  useEffect(() => {
    if (!editor || headings.length === 0) return;

    const root = scrollContainer ?? null;
    const visibleSet = new Map<Element, number>();

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            visibleSet.set(entry.target, headings.findIndex(
              (h) => resolveHeadingElement(editor, h.pos) === entry.target,
            ));
          } else {
            visibleSet.delete(entry.target);
          }
        }
        let bestIdx = -1;
        for (const idx of visibleSet.values()) {
          if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) bestIdx = idx;
        }
        if (visibleSet.size > 0) {
          setActivePos(bestIdx >= 0 ? headings[bestIdx].pos : null);
        }
      },
      {
        root,
        rootMargin: `-${SCROLL_OFFSET_PX}px 0px 0px 0px`,
        threshold: 0,
      },
    );

    const elements: HTMLElement[] = [];
    for (const h of headings) {
      const el = resolveHeadingElement(editor, h.pos);
      if (el) {
        observer.observe(el);
        elements.push(el);
      }
    }

    return () => observer.disconnect();
  }, [editor, headings, scrollContainer]);

  const tree = useMemo(() => buildTree(headings), [headings]);

  const expandedPositions = useMemo(() => {
    const set = new Set<number>();
    if (activePos == null) return set;

    const walk = (nodes: TocNode[], ancestors: number[]): boolean => {
      for (const node of nodes) {
        if (node.pos === activePos) {
          ancestors.forEach((p) => set.add(p));
          set.add(node.pos);
          return true;
        }
        if (node.children.length > 0 && walk(node.children, [...ancestors, node.pos])) {
          return true;
        }
      }
      return false;
    };
    walk(tree, []);
    return set;
  }, [tree, activePos]);

  const scrollTo = useCallback((pos: number) => {
    if (!editor) return;
    const el = resolveHeadingElement(editor, pos);
    if (el) {
      el.scrollIntoView({ behavior: "smooth", block: "start" });
    }
    editor.chain().focus().setTextSelection(pos).run();
    onNavigate?.();
  }, [editor, onNavigate]);

  if (!editor || headings.length === 0) {
    return (
      <div className="table-of-contents">
        <div className="toc-title">Table of contents</div>
        <div className="toc-empty">Headings will appear here as you write.</div>
      </div>
    );
  }

  const renderList = (nodes: TocNode[], isRoot: boolean) => (
    <ul>
      {nodes.map((node) => {
        const isExpanded = expandedPositions.has(node.pos);
        const hasChildren = node.children.length > 0;

        return (
          <li key={node.id}>
            <a
              className={activePos === node.pos ? "active" : undefined}
              onClick={(e) => {
                e.preventDefault();
                scrollTo(node.pos);
              }}
            >
              {node.text}
            </a>
            {hasChildren && (
              <div
                className="toc-children"
                style={autoCollapse ? {
                  gridTemplateRows: isExpanded ? "1fr" : "0fr",
                  opacity: isExpanded ? 1 : 0,
                } : {
                  gridTemplateRows: "1fr",
                  opacity: 1,
                }}
              >
                {renderList(node.children, false)}
              </div>
            )}
          </li>
        );
      })}
    </ul>
  );

  return (
    <div className="table-of-contents">
      <div className="toc-title">Table of contents</div>
      <nav>
        {renderList(tree, true)}
      </nav>
    </div>
  );
}
