import React, { useRef, useState, useCallback, useEffect } from "react";
import { Editor as TiptapEditor } from "@tiptap/core";
import { UndoManager } from "yjs";
import type * as Y from "yjs";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import { MessageCircle } from "lucide-react";
import { Tooltip } from "./components/Tooltip";
import { oklchFromHue } from "#shared/theme";
import { Editor } from "./editor/Editor";
import { CommentMarginIcons } from "./components/CommentPopover";
import { CommentDialog } from "./components/CommentDialog";
import { ChatPanel, type ModelOption } from "./components/ChatPanel";
import { TableOfContents } from "./components/TableOfContents";
import { TopBar } from "./components/TopBar";
import { PublishDialog, type PublishState, type PublishStageEvent } from "./components/PublishDialog";
import { MobileTocSidebar } from "./components/MobileTocSidebar";
import { useAgentChat } from "./hooks/useAgentChat";
import { usePublishStatus } from "./hooks/usePublishStatus";
import type { CommentStore } from "./editor/comments";
import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
import type { EmbedStore } from "./editor/embeds/embed-store";
import type { EmbedDataStore } from "./editor/embeds/embed-data-store";
import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
import { EditorFooter } from "./editor/EditorFooter";
import { EmbedStudio } from "./components/EmbedStudio";
import type { UIMessage } from "ai";
import { loadMessages, saveMessages } from "./utils/chat-persistence";
import { stableFallbackUser, colorFromName, type CollabUser } from "./utils/user";

// Re-exported for any downstream code that still imports from App.tsx
export type { CollabUser } from "./utils/user";

export default function App() {
  // --- Identity & auth ---------------------------------------------------

  const [user, setUser] = useState<CollabUser>(stableFallbackUser);
  const [loginUrl, setLoginUrl] = useState<string | null>(null);
  const [isAuthenticated, setIsAuthenticated] = useState(true);
  // Defaults to true so the UI is permissive until /api/auth/status answers.
  // When OAuth is disabled (local dev), the backend always returns canEdit=true.
  const [canEdit, setCanEdit] = useState(true);
  const [chatUserId, setChatUserId] = useState(() => user.name);

  useEffect(() => {
    fetch("/api/auth/status", { credentials: "include" })
      .then((r) => r.json())
      .then((data) => {
        setIsAuthenticated(data.authenticated);
        setCanEdit(Boolean(data.canEdit));
        if (data.authenticated && data.user) {
          const name = data.user.fullName || data.user.name;
          setUser({
            name,
            color: colorFromName(data.user.name),
            avatarUrl: data.user.avatarUrl || undefined,
          });
          setChatUserId(data.user.name);
          setLoginUrl(null);
        } else if (data.loginUrl) {
          setLoginUrl(data.loginUrl);
        }
      })
      .catch(() => {});
  }, []);

  // --- Document / editor refs -------------------------------------------

  const docName = "default";
  const editorRef = useRef<TiptapEditor | null>(null);
  const editorContainerRef = useRef<HTMLDivElement | null>(null);
  const providerRef = useRef<HocuspocusProvider | null>(null);
  const publishDialogRef = useRef<HTMLDialogElement>(null);

  const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
  const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null);
  const [embedDataStore, setEmbedDataStore] = useState<EmbedDataStore | null>(null);
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);

  const [isEditorReady, setIsEditorReady] = useState(false);

  // --- UI state ---------------------------------------------------------

  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [chatOpen, setChatOpen] = useState(false);
  const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null);
  // Stable session id for the currently-open EmbedStudio instance.
  // Used as the React key so the component does NOT remount when the
  // agent renames the chart file mid-session (rename flips
  // `embedStudioSrc`, but the same studio instance keeps its chat
  // state).
  const [embedStudioSession, setEmbedStudioSession] = useState<string | null>(null);
  const [tocSidebarOpen, setTocSidebarOpen] = useState(false);
  const [tocAutoCollapse, setTocAutoCollapse] = useState(false);

  const [publishState, setPublishState] = useState<PublishState>("idle");
  const [publishError, setPublishError] = useState("");
  const [publishStage, setPublishStage] = useState<PublishStageEvent | null>(null);
  const [yDoc, setYDoc] = useState<Y.Doc | null>(null);
  const selectionRange = useRef<{ from: number; to: number } | null>(null);

  const publishStatus = usePublishStatus(yDoc);

  // --- Theme ------------------------------------------------------------

  const [theme, setTheme] = useState<"light" | "dark">(() => {
    return (document.documentElement.getAttribute("data-theme") as "light" | "dark") || "dark";
  });

  useEffect(() => {
    const saved = localStorage.getItem("theme") as "light" | "dark" | null;
    if (saved) {
      document.documentElement.setAttribute("data-theme", saved);
      setTheme(saved);
    }
  }, []);

  const toggleTheme = useCallback(() => {
    const next = theme === "dark" ? "light" : "dark";
    document.documentElement.setAttribute("data-theme", next);
    localStorage.setItem("theme", next);
    setTheme(next);
  }, [theme]);

  // --- Model list -------------------------------------------------------

  const [models, setModels] = useState<ModelOption[]>([]);
  const [selectedModel, setSelectedModel] = useState("");
  const modelRef = useRef("");

  useEffect(() => {
    fetch("/api/models")
      .then((r) => r.json())
      .then((data: ModelOption[]) => {
        setModels(data);
        if (data.length > 0 && !modelRef.current) {
          setSelectedModel(data[0].id);
          modelRef.current = data[0].id;
        }
      })
      .catch(() => {});
  }, []);

  const handleModelChange = useCallback((id: string) => {
    setSelectedModel(id);
    modelRef.current = id;
  }, []);

  // --- Effects tied to stores -------------------------------------------

  // Sync TOC auto-collapse flag from frontmatter
  useEffect(() => {
    if (!frontmatterStore) return;
    const sync = () => {
      setTocAutoCollapse(frontmatterStore.get("tableOfContentsAutoCollapse") as boolean);
    };
    sync();
    return frontmatterStore.observe(sync);
  }, [frontmatterStore]);

  // Fallback: dismiss spinner after 5s even if sync hasn't fired
  useEffect(() => {
    if (isEditorReady) return;
    const id = setTimeout(() => setIsEditorReady(true), 5000);
    return () => clearTimeout(id);
  }, [isEditorReady]);

  // Sync primary hue from Yjs settings to CSS variables
  useEffect(() => {
    if (!settingsMap) return;
    const sync = () => {
      const h = settingsMap.get("primaryHue") as number | undefined;
      if (h !== undefined) {
        // Only override --primary-base: _variables.css derives --primary-color
        // and --primary-color-hover from it via CSS relative-color syntax.
        document.documentElement.style.setProperty("--primary-base", oklchFromHue(h));
      } else {
        // When the Yjs settings map has no primaryHue (fresh state or after
        // reset-article), drop the inline override so the CSS default from
        // _variables.css wins. Otherwise a stale hue from a previous session
        // survives the reset visually, and the demo has to re-paint it at
        // boot time (which looks like an unmotivated color jump).
        document.documentElement.style.removeProperty("--primary-base");
      }
    };
    sync();
    settingsMap.observe(sync);
    return () => settingsMap.unobserve(sync);
  }, [settingsMap]);

  // Listen for embed studio open events from HtmlEmbedView
  useEffect(() => {
    const handler = (e: Event) => {
      const src = (e as CustomEvent).detail?.src;
      if (src) {
        setEmbedStudioSrc(src);
        setEmbedStudioSession(`es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
      }
    };
    window.addEventListener("open-embed-studio", handler);
    return () => window.removeEventListener("open-embed-studio", handler);
  }, []);

  // Rename every htmlEmbed node referencing `oldSrc` to `newSrc` in the
  // ProseMirror doc, then lift the new src into state so the
  // EmbedStudio props and the document stay consistent. The agent
  // triggers this from createEmbed({ filename }) inside useEmbedChat.
  const handleEmbedRename = useCallback(
    (oldSrc: string, newSrc: string) => {
      const editor = editorRef.current;
      if (editor) {
        const { state } = editor;
        const tr = state.tr;
        let changed = false;
        state.doc.descendants((node, pos) => {
          if (node.type.name === "htmlEmbed" && node.attrs.src === oldSrc) {
            tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc });
            changed = true;
          }
        });
        if (changed) editor.view.dispatch(tr);
      }
      setEmbedStudioSrc((prev) => (prev === oldSrc ? newSrc : prev));
    },
    [],
  );

  // Global entry point for the Embed Studio (TopBar button). Smart
  // behaviour: if charts already exist, open the studio on one of them
  // (browse/edit mode via the FilesSidebar) without touching the
  // document. Only when there is no chart at all do we fall back to
  // creating a fresh one - mirroring the slash menu's "New Chart" flow.
  // Both paths route through the existing `open-embed-studio` listener
  // so session/key handling stays in one place.
  const handleOpenEmbedStudio = useCallback(() => {
    const keys = embedStore ? embedStore.keys() : [];
    if (keys.length > 0) {
      // Prefer a real chart over the always-present banner so the
      // studio lands on content the user most likely wants to tweak.
      const preferred = keys.find((k) => k !== "banner.html") ?? keys[0];
      window.dispatchEvent(
        new CustomEvent("open-embed-studio", { detail: { src: preferred } }),
      );
      return;
    }

    const editor = editorRef.current;
    if (!editor) return;
    const id = `d3-chart-${Date.now().toString(36)}`;
    const src = `${id}.html`;
    (editor.chain().focus() as any).insertHtmlEmbed().run();
    setTimeout(() => {
      const { doc } = editor.state;
      let targetPos = -1;
      doc.descendants((node, pos) => {
        if (node.type.name === "htmlEmbed" && !node.attrs.src) {
          targetPos = pos;
          return false;
        }
      });
      if (targetPos >= 0) {
        editor.view.dispatch(
          editor.state.tr.setNodeMarkup(targetPos, undefined, {
            ...editor.state.doc.nodeAt(targetPos)?.attrs,
            src,
            title: "New chart",
          }),
        );
      }
      window.dispatchEvent(
        new CustomEvent("open-embed-studio", { detail: { src } }),
      );
    }, 50);
  }, [embedStore]);

  // When the user opens the chat with an active selection, broadcast it
  // via Yjs awareness so every collaborator (including us) sees a
  // persistent highlight "my agent is working on this range". The
  // AgentFocus extension handles CRDT-safe position tracking; we just
  // toggle it on/off based on chat state. Safe w.r.t. undo: the
  // extension only writes to awareness, it never emits a Yjs-tracked
  // transaction.
  //
  // We listen to `selectionUpdate` while the chat is open so that:
  //   - if the selection was not yet readable at chat-open time
  //     (focus/blur race when clicking the FAB), the first selection
  //     update still syncs the highlight;
  //   - if the user selects new text while the chat is open, the
  //     highlight follows the new range.
  // On collapsed selection we leave the previously broadcast focus in
  // place - the user may have clicked in the chat textarea without
  // wanting to lose the agent's current target.
  useEffect(() => {
    if (!editorInstance) return;
    if (!chatOpen) {
      editorInstance.commands.clearAgentFocus();
      return;
    }

    const syncAgentFocus = () => {
      const { from, to } = editorInstance.state.selection;
      if (from === to) return;
      editorInstance.commands.setAgentFocus({ from, to });
    };

    syncAgentFocus();
    editorInstance.on("selectionUpdate", syncAgentFocus);
    return () => {
      editorInstance.off("selectionUpdate", syncAgentFocus);
    };
  }, [chatOpen, editorInstance]);

  // --- Chat / agent -----------------------------------------------------

  const chatUserIdRef = useRef(chatUserId);
  chatUserIdRef.current = chatUserId;

  const handleArticleChatChange = useCallback((msgs: UIMessage[]) => {
    saveMessages(chatUserIdRef.current, "article", msgs);
  }, []);

  const agentChat = useAgentChat({
    editor: editorInstance,
    undoManager,
    frontmatterStore,
    modelRef,
    onMessagesChange: handleArticleChatChange,
  });

  // Restore persisted messages after mount and when user identity changes
  const agentSetMessagesRef = useRef(agentChat.setMessages);
  agentSetMessagesRef.current = agentChat.setMessages;

  const chatUserIdLoadedRef = useRef<string | null>(null);
  useEffect(() => {
    if (chatUserIdLoadedRef.current === chatUserId) return;
    chatUserIdLoadedRef.current = chatUserId;
    const stored = loadMessages(chatUserId, "article");
    if (stored && stored.length > 0) {
      agentSetMessagesRef.current(stored);
    }
  }, [chatUserId]);

  // Dev-only hook: let the demo recording script script-drive the main
  // chat panel. The Playwright showcase script dispatches `__demo-chat` to:
  //   - open the floating chat panel,
  //   - inject a fake user prompt + assistant reply,
  //   - optionally rewrite a paragraph in the editor body,
  // all without waiting for a real LLM round-trip.
  useEffect(() => {
    if (!import.meta.env.DEV) return;
    const handler = (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | {
            open?: boolean;
            messages?: UIMessage[];
            replace?: {
              from: string;
              to: string;
              /**
               * Explicit PM positions to replace. When provided we use
               * them as-is and skip the (fragile) string search below.
               * Required for paragraphs that contain non-text leaf
               * nodes (inline math, mentions, images...) because their
               * textContent does NOT match the `from` plain-string.
               */
              range?: { from: number; to: number };
              /**
               * "none"       -> atomic replace in one transaction. The
               *                 viewer sees the new phrase "snap in",
               *                 unambiguously an AI edit.
               * "typewriter" -> stream chars at human cadence. Looks
               *                 like the owner is typing (which is
               *                 NOT what we want for an agent action -
               *                 use "none" for rephrase actions so
               *                 they don't look like user input).
               * Default: "typewriter" for backwards compatibility.
               */
              animation?: "none" | "typewriter";
              /**
               * Keep an AgentFocus decoration on the replacement
               * range for `lingerAgentFocusMs` ms after the swap so
               * the viewer sees the "<Name> agent" label hover over
               * the new sentence - visual proof the agent owned that
               * edit. Ignored when 0 / undefined (caller will manage
               * focus clearing itself).
               */
              lingerAgentFocusMs?: number;
            };
            /**
             * Apply a formatting mark (bold/italic/strike/code) to an
             * explicit PM range. Used by the demo to showcase the
             * agent calling a simple "make this bold" tool - the same
             * path a real agent's `toggleMark` tool call would take.
             */
            format?: {
              mark: "bold" | "italic" | "strike" | "code";
              range: { from: number; to: number };
            };
            /**
             * Wrap a PM range in a Link mark. Same pipeline as `format`
             * (explicit PM range, go through Tiptap's chain) so the
             * new mark lands collaboratively via Yjs and participates
             * in the normal undo stack.
             */
            link?: {
              url: string;
              range: { from: number; to: number };
            };
          }
        | undefined;
      if (typeof detail?.open === "boolean") setChatOpen(detail.open);
      if (Array.isArray(detail?.messages)) {
        agentSetMessagesRef.current(detail.messages);
      }
      if (detail?.replace && editorRef.current) {
        const editor = editorRef.current;
        const target = detail.replace.from;
        const replacement = detail.replace.to;
        const explicitRange = detail.replace.range;
        const animation = detail.replace.animation ?? "typewriter";
        const lingerAgentFocusMs = detail.replace.lingerAgentFocusMs ?? 0;

        // Resolve the target range AT DISPATCH TIME, not here. We used
        // to do the string search on event receipt, cache from/to,
        // then setTimeout(520) into `agentRewriteRange`. Problem: the
        // assistant reply was still streaming AND remote peers kept
        // typing during those 520ms. Any insert before the cached
        // range would silently shift Alice's target by N chars, and
        // the typewriter would then eat Bob's sentence or the wrong
        // half of Alice's own paragraph. Running the lookup inside
        // the setTimeout reads the LIVE doc at the actual kickoff
        // moment, so concurrent edits can't invalidate it.
        window.setTimeout(() => {
          const ed = editorRef.current;
          if (!ed) return;

          let from = -1;
          let to = -1;

          // Preferred path: explicit PM range from the caller. Must
          // still be clamped against the LIVE doc size in case remote
          // edits shrank the doc since the event fired.
          if (explicitRange) {
            const size = ed.state.doc.content.size;
            const a = Math.max(0, Math.min(explicitRange.from, size));
            const b = Math.max(0, Math.min(explicitRange.to, size));
            if (b > a) {
              from = a;
              to = b;
            } else {
              console.error(
                "[__demo-chat] replace.range is empty or inverted:",
                explicitRange,
              );
            }
          }

          // Fallback: walk the LIVE doc and find the first textblock
          // whose content contains `target`. Because this runs at
          // dispatch time (not at event-receipt time) no remote edit
          // can race the result.
          if (from === -1) {
            ed.state.doc.descendants((node, pos) => {
              if (from !== -1) return false;
              if (node.isTextblock) {
                const text = node.textBetween(
                  0,
                  node.content.size,
                  "\n",
                  "\n",
                );
                const idx = text.indexOf(target);
                if (idx !== -1) {
                  from = pos + 1 + idx;
                  to = from + target.length;
                  return false;
                }
              }
              return true;
            });
          }

          if (from === -1 || to === -1) {
            console.error(
              "[__demo-chat] replace target not found:",
              target.slice(0, 60) + (target.length > 60 ? "..." : ""),
              "- pass `replace.range` to target paragraphs with inline atoms",
            );
            return;
          }

          // Opportunistic scroll: AgentRewrite maps its own cursor
          // through every subsequent tr, so this DOM lookup only
          // needs to be approximately right.
          try {
            const domAt = ed.view.domAtPos(from);
            let el: HTMLElement | null =
              domAt.node instanceof HTMLElement
                ? domAt.node
                : domAt.node.parentElement;
            while (
              el &&
              el.nodeName !== "P" &&
              !/^H[1-6]$/.test(el.nodeName)
            ) {
              el = el.parentElement;
            }
            if (el && typeof el.scrollIntoView === "function") {
              el.scrollIntoView({ behavior: "smooth", block: "center" });
            }
          } catch {
            /* non-fatal */
          }

          ed.commands.agentRewriteRange({
            from,
            to,
            text: replacement,
            animation,
          });

          // Re-seed the AgentFocus on the REPLACEMENT range after the
          // swap so the "<Name> agent" label keeps hovering over the
          // new sentence - otherwise the decoration collapses during
          // the delete-then-insert, and viewers perceive the new
          // phrase as user typing instead of an agent edit. For the
          // atomic "none" animation the new text is present
          // immediately; for the typewriter we wait long enough for
          // the first chunk to land so PM positions are stable.
          if (lingerAgentFocusMs > 0) {
            const seedDelay = animation === "none" ? 0 : 80;
            window.setTimeout(() => {
              const live = editorRef.current;
              if (!live) return;
              const newFrom = from;
              const newTo = from + replacement.length;
              const size = live.state.doc.content.size;
              const a = Math.max(0, Math.min(newFrom, size));
              const b = Math.max(0, Math.min(newTo, size));
              if (b > a) {
                live.commands.setAgentFocus({ from: a, to: b });
              }
              window.setTimeout(() => {
                const again = editorRef.current;
                again?.commands.clearAgentFocus();
              }, lingerAgentFocusMs);
            }, seedDelay);
          }
        }, 520);
      }

      // ---- Format action: bold / italic / strike / code ----------------
      // The agent's simplest possible tool call: "apply mark M to
      // range [from, to]". We scroll the target into view and route
      // through Tiptap's own `setMark` commands so the operation goes
      // through the real editor pipeline (Yjs syncs, undo step, mark
      // extension hooks) - same path a production agent tool would
      // take. No custom animation: the viewer sees the text turn
      // bold in a single frame, which reads as an instant agent edit.
      if (detail?.format && editorRef.current) {
        const editor = editorRef.current;
        const size = editor.state.doc.content.size;
        const from = Math.max(0, Math.min(detail.format.range.from, size));
        const to = Math.max(0, Math.min(detail.format.range.to, size));
        if (to <= from) {
          console.error(
            "[__demo-chat] format range empty or inverted:",
            detail.format.range,
          );
          return;
        }
        try {
          const domAt = editor.view.domAtPos(from);
          let el: HTMLElement | null =
            domAt.node instanceof HTMLElement
              ? domAt.node
              : domAt.node.parentElement;
          while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
            el = el.parentElement;
          }
          if (el && typeof el.scrollIntoView === "function") {
            el.scrollIntoView({ behavior: "smooth", block: "center" });
          }
        } catch {
          /* non-fatal */
        }
        const chain = editor
          .chain()
          .focus()
          .setTextSelection({ from, to });
        switch (detail.format.mark) {
          case "bold":
            chain.setBold().run();
            break;
          case "italic":
            chain.setItalic().run();
            break;
          case "strike":
            chain.setStrike().run();
            break;
          case "code":
            chain.setCode().run();
            break;
          default:
            console.error(
              "[__demo-chat] unknown format mark:",
              detail.format.mark,
            );
        }
      }

      // ---- Link action: wrap a PM range in a Link mark ------------------
      // Goes through Tiptap's `setLink` command (same path the bubble
      // toolbar uses) so the mark syncs over Yjs, respects safe-URL
      // validation, and lands in the undo stack. Scrolls the target
      // into view before applying so the viewer sees the link underline
      // appear on the actual phrase.
      //
      // Drift-proof resolution
      // ----------------------
      // The caller MAY pass an explicit PM `range`, but doc positions
      // are fragile under concurrent edits (another peer inserting
      // content earlier in the doc will shift every subsequent
      // position). So we prefer the substring-based path when the
      // caller provides `paragraphSnippet` + `substring`: walk the
      // LIVE doc at dispatch time, find the matching text node, and
      // resolve fresh PM positions. The explicit range only wins if
      // the text at [from..to] still matches `substring`. Otherwise
      // we fall back to the search, which is always correct as long
      // as the target text hasn't been edited away.
      const detailLink = (detail as unknown as {
        link?: {
          url: string;
          range?: { from: number; to: number };
          paragraphSnippet?: string;
          substring?: string;
        };
      }).link;
      if (detailLink && editorRef.current) {
        const editor = editorRef.current;
        const size = editor.state.doc.content.size;
        const url = (detailLink.url || "").trim();
        if (!url) {
          console.error("[__demo-chat] link URL missing:", detailLink);
          return;
        }

        const resolveBySubstring = (): { from: number; to: number } | null => {
          if (!detailLink.paragraphSnippet || !detailLink.substring) return null;
          let found: { from: number; to: number } | null = null;
          editor.state.doc.descendants((node, pos) => {
            if (found) return false;
            if (!node.isTextblock) return true;
            const text = node.textBetween(0, node.content.size, "\n", "\n");
            if (!text.includes(detailLink.paragraphSnippet!)) return true;
            const idx = text.indexOf(detailLink.substring!);
            if (idx === -1) return false;
            found = {
              from: pos + 1 + idx,
              to: pos + 1 + idx + detailLink.substring!.length,
            };
            return false;
          });
          return found;
        };

        let from = -1;
        let to = -1;
        if (detailLink.range) {
          const tentativeFrom = Math.max(0, Math.min(detailLink.range.from, size));
          const tentativeTo = Math.max(0, Math.min(detailLink.range.to, size));
          if (tentativeTo > tentativeFrom) {
            const textAtRange = editor.state.doc.textBetween(
              tentativeFrom,
              tentativeTo,
            );
            const rangeStillMatches =
              !detailLink.substring || textAtRange === detailLink.substring;
            if (rangeStillMatches) {
              from = tentativeFrom;
              to = tentativeTo;
            }
          }
        }
        if (from === -1) {
          const resolved = resolveBySubstring();
          if (resolved) {
            from = resolved.from;
            to = resolved.to;
          }
        }
        if (from === -1 || to <= from) {
          console.error(
            "[__demo-chat] link target not resolvable:",
            detailLink,
          );
          return;
        }
        try {
          const domAt = editor.view.domAtPos(from);
          let el: HTMLElement | null =
            domAt.node instanceof HTMLElement
              ? domAt.node
              : domAt.node.parentElement;
          while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
            el = el.parentElement;
          }
          if (el && typeof el.scrollIntoView === "function") {
            el.scrollIntoView({ behavior: "smooth", block: "center" });
          }
        } catch {
          /* non-fatal */
        }
        editor
          .chain()
          .focus()
          .setTextSelection({ from, to })
          .setLink({ href: url })
          .run();
      }

      // ---- Citation action: seed an entry in the shared citations map
      // then insert an inline citation node at a PM position, and make
      // sure a <bibliography> block exists at the end of the doc.
      //
      // Same pipeline as the real CitationPanel (see CitationPanel.tsx):
      // write a CSL-JSON entry into `citationsMap` with a key, dispatch
      // `insertCitation(key)` on the editor, then append a bibliography
      // section if the doc doesn't have one yet. This exercises the
      // production code path end to end - chip auto-labelling included.
      const detailCitation = (detail as unknown as {
        citation?: {
          key: string;
          entry: unknown;
          at?: number;
          paragraphSnippet?: string;
          anchor?: "end" | "start";
        };
      }).citation;
      if (detailCitation && editorRef.current) {
        const editor = editorRef.current;
        const size = editor.state.doc.content.size;

        // Drift-proof anchor resolution: if `paragraphSnippet` is
        // provided, walk the LIVE doc for the matching paragraph and
        // return its start or end position. Otherwise fall back to the
        // explicit `at` (which can be stale under concurrent upstream
        // edits). Same philosophy as the link resolver above.
        const resolveByParagraph = (): number | null => {
          if (!detailCitation.paragraphSnippet) return null;
          const anchor = detailCitation.anchor ?? "end";
          let found: number | null = null;
          editor.state.doc.descendants((node, pos) => {
            if (found !== null) return false;
            if (!node.isTextblock) return true;
            const text = node.textBetween(0, node.content.size, "\n", "\n");
            if (!text.includes(detailCitation.paragraphSnippet!)) return true;
            found = anchor === "end" ? pos + node.nodeSize - 1 : pos + 1;
            return false;
          });
          return found;
        };

        let at = -1;
        const resolved = resolveByParagraph();
        if (resolved !== null) {
          at = resolved;
        } else if (typeof detailCitation.at === "number") {
          at = Math.max(0, Math.min(detailCitation.at, size));
        }
        if (at < 0) {
          console.error(
            "[__demo-chat] citation target not resolvable:",
            detailCitation,
          );
          return;
        }
        const citationsMap = (editor.storage as unknown as Record<string, unknown>)
          .citation as { citationsMap?: { set: (k: string, v: unknown) => void } } | undefined;
        if (!citationsMap?.citationsMap) {
          console.error(
            "[__demo-chat] citationsMap not attached to editor storage",
          );
          return;
        }
        citationsMap.citationsMap.set(detailCitation.key, detailCitation.entry);
        try {
          const domAt = editor.view.domAtPos(at);
          let el: HTMLElement | null =
            domAt.node instanceof HTMLElement
              ? domAt.node
              : domAt.node.parentElement;
          while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
            el = el.parentElement;
          }
          if (el && typeof el.scrollIntoView === "function") {
            el.scrollIntoView({ behavior: "smooth", block: "center" });
          }
        } catch {
          /* non-fatal */
        }
        editor
          .chain()
          .focus()
          .setTextSelection(at)
          .insertCitation(detailCitation.key)
          .run();
        let hasBibliography = false;
        editor.state.doc.descendants((node) => {
          if (node.type.name === "bibliography") hasBibliography = true;
        });
        if (!hasBibliography) {
          const endPos = editor.state.doc.content.size;
          editor
            .chain()
            .insertContentAt(endPos, [
              { type: "paragraph" },
              { type: "bibliography" },
            ])
            .run();
        }
      }
    };
    window.addEventListener("__demo-chat", handler);
    return () => window.removeEventListener("__demo-chat", handler);
  }, []);

  // Dev-only: imperative settings mutation for the demo (primary hue).
  // Writing straight to the shared Yjs `settings` map gives every peer
  // the updated colour via the normal observer path - exactly what
  // moving the HueSlider does, minus the UI detour.
  useEffect(() => {
    if (!import.meta.env.DEV) return;
    if (!settingsMap) return;
    const handler = (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | { hue?: number }
        | undefined;
      if (typeof detail?.hue === "number") {
        const clamped = Math.max(0, Math.min(360, Math.round(detail.hue)));
        settingsMap.set("primaryHue", clamped);
      }
    };
    window.addEventListener("__demo-settings", handler);
    return () => window.removeEventListener("__demo-settings", handler);
  }, [settingsMap]);

  // Dev-only: seed or clear data files in the EmbedDataStore. Lets the
  // showcase demo populate the FilesSidebar with a realistic CSV before the
  // agent "reads" it, without requiring a real drag-and-drop upload.
  useEffect(() => {
    if (!import.meta.env.DEV) return;
    if (!embedDataStore) return;
    const handler = (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | {
            clear?: boolean;
            file?: {
              name: string;
              ext?: string;
              content: string;
              columns?: string[];
              rowCount?: number;
              uploader?: string;
            };
          }
        | undefined;
      if (!detail) return;
      if (detail.clear) {
        for (const key of embedDataStore.keys()) embedDataStore.remove(key);
        return;
      }
      if (detail.file) {
        const f = detail.file;
        const ext =
          (f.ext ?? f.name.split(".").pop() ?? "txt").toLowerCase();
        embedDataStore.set({
          meta: {
            name: f.name,
            ext,
            size: new Blob([f.content]).size,
            uploader: f.uploader ?? "demo",
            addedAt: Date.now(),
            rowCount: f.rowCount,
            columns: f.columns,
          },
          content: f.content,
        });
      }
    };
    window.addEventListener("__demo-embed-data", handler);
    return () => window.removeEventListener("__demo-embed-data", handler);
  }, [embedDataStore]);

  // Dev-only: write arbitrary embed HTML (for charts beyond the banner)
  // and optionally rename an existing src so the demo can showcase the
  // agent-picked filename live. Mirrors what the real createEmbed tool
  // does via useEmbedChat when the agent provides a `filename`.
  useEffect(() => {
    if (!import.meta.env.DEV) return;
    if (!embedStore) return;
    const handler = (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | { src?: string; html?: string; renameFrom?: string }
        | undefined;
      if (!detail?.src || typeof detail.html !== "string") return;
      const { src, html, renameFrom } = detail;
      if (renameFrom && renameFrom !== src) {
        embedStore.set(src, html);
        embedStore.remove(renameFrom);
        handleEmbedRename(renameFrom, src);
      } else {
        embedStore.set(src, html);
      }
    };
    window.addEventListener("__demo-embed-set", handler);
    return () => window.removeEventListener("__demo-embed-set", handler);
  }, [embedStore, handleEmbedRename]);

  // --- Editor lifecycle callbacks ---------------------------------------

  const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
    editorContainerRef.current = node;
    setContainerEl(node);
  }, []);

  const onEditorReady = useCallback((editor: TiptapEditor | null) => {
    editorRef.current = editor;
    setEditorInstance(editor);
    // Dev-only: expose the Tiptap editor for the demo script so it can
    // drive PM selection natively (which the collaboration-cursor
    // extension then broadcasts via Yjs awareness so Bob and Carol see
    // Alice's selected range in her persona color).
    if (import.meta.env.DEV) {
      (window as unknown as { __demoEditor?: TiptapEditor | null }).__demoEditor =
        editor;
    }
  }, []);

  const onProviderReady = useCallback((provider: HocuspocusProvider) => {
    providerRef.current = provider;
    setYDoc(provider.document);
    provider.on("synced", () => setIsEditorReady(true));
  }, []);

  const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []);
  const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []);
  const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []);
  const onEmbedDataStoreReady = useCallback(
    (store: EmbedDataStore) => setEmbedDataStore(store),
    [],
  );
  const onSettingsMapReady = useCallback((map: Y.Map<any>) => setSettingsMap(map), []);

  // --- Publish flow -----------------------------------------------------

  const publishEventSourceRef = useRef<EventSource | null>(null);

  const openPublishDialog = useCallback(() => {
    setPublishState("idle");
    setPublishError("");
    setPublishStage(null);
    publishDialogRef.current?.showModal();
  }, []);

  const closePublishDialog = useCallback(() => {
    publishDialogRef.current?.close();
  }, []);

  useEffect(() => {
    return () => {
      publishEventSourceRef.current?.close();
      publishEventSourceRef.current = null;
    };
  }, []);

  // Dev-only: demo hook that opens the Publish dialog without hitting
  // the real server. The viewer sees the modal slide in at the very
  // end of the scenario so the final frame is "hero agent ready to
  // ship the article" - without actually kicking off a publish job.
  useEffect(() => {
    if (!import.meta.env.DEV) return;
    const handler = (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | { open?: boolean }
        | undefined;
      if (detail?.open) {
        openPublishDialog();
      } else if (detail?.open === false) {
        publishDialogRef.current?.close();
      }
    };
    window.addEventListener("__demo-publish", handler);
    return () => window.removeEventListener("__demo-publish", handler);
  }, [openPublishDialog]);

  const handlePublish = useCallback(async () => {
    setPublishState("loading");
    setPublishError("");
    setPublishStage(null);

    try {
      const res = await fetch("/api/publish", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ docName }),
      });

      if (res.status === 409) {
        const data = await res.json().catch(() => ({ error: "Publish already in progress" }));
        throw new Error(
          data.userName
            ? `${data.userName} is already publishing. Please wait.`
            : "A publish is already in progress. Please wait."
        );
      }
      if (!res.ok) {
        const data = await res.json().catch(() => ({ error: "Publish failed" }));
        throw new Error(data.error || "Publish failed");
      }

      const { jobId } = (await res.json()) as { jobId: string };
      if (!jobId) throw new Error("No job id returned");

      // Open the SSE stream and forward stage events to the dialog.
      const es = new EventSource(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`, {
        withCredentials: true,
      });
      publishEventSourceRef.current = es;

      es.addEventListener("stage", (e: MessageEvent) => {
        try {
          const payload = JSON.parse(e.data) as PublishStageEvent;
          setPublishStage(payload);
        } catch {
          // ignore malformed payloads
        }
      });

      es.addEventListener("done", (e: MessageEvent) => {
        try {
          const payload = JSON.parse(e.data) as { success: boolean; error?: string };
          if (payload.success) {
            setPublishState("success");
          } else {
            setPublishState("error");
            setPublishError(payload.error || "Publish failed");
          }
        } catch {
          setPublishState("error");
          setPublishError("Malformed server event");
        } finally {
          es.close();
          publishEventSourceRef.current = null;
        }
      });

      es.onerror = () => {
        // Network blip or server closed the stream unexpectedly. If we haven't
        // already reached a terminal state, mark the publish as errored.
        if (es.readyState === EventSource.CLOSED) {
          publishEventSourceRef.current = null;
          setPublishState((prev) => (prev === "loading" ? "error" : prev));
          setPublishError((prev) => prev || "Connection to publish stream lost");
        }
      };
    } catch (err: any) {
      setPublishState("error");
      setPublishError(err.message || "Unknown error");
    }
  }, [docName]);

  // --- Comment flow -----------------------------------------------------

  const handleAddComment = useCallback(() => {
    const editor = editorRef.current;
    if (!editor) return;
    const { from, to } = editor.state.selection;
    if (from === to) return;
    selectionRange.current = { from, to };
    setCommentDialogOpen(true);
  }, []);

  const handleCommentSubmit = useCallback((text: string) => {
    const editor = editorRef.current;
    if (!editor || !commentStore || !selectionRange.current) return;

    const { from, to } = selectionRange.current;
    const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;

    commentStore.add({
      id,
      author: user.name,
      authorColor: user.color,
      text,
      createdAt: Date.now(),
      resolved: false,
    });

    editor
      .chain()
      .focus()
      .setTextSelection({ from, to })
      .setComment(id)
      .run();

    selectionRange.current = null;
  }, [commentStore, user]);

  // --- Render -----------------------------------------------------------

  return (
    <div
      className={`editor-app${chatOpen ? " editor-app--chat-open" : ""}`}
      style={
        {
          // Expose the current user's color (with ~44% alpha) as a CSS
          // variable so the global `::selection` rule can recolor every
          // native selection in the app with the user's identity color.
          // Using the native selection keeps the full line-height of the
          // selected text (no more "shorter than native" custom tint).
          //
          // When `editor-app--chat-open` is active the native selection
          // is hidden *inside the editor only* (see `_ui.css`) so the
          // AgentFocus PM decoration can take over as the single source
          // of truth for the selected range - this avoids stacking two
          // tints when the editor still has DOM focus after opening the
          // chat panel.
          "--local-selection-bg": `${user.color}70`,
        } as React.CSSProperties
      }
    >
      <TopBar
        editorInstance={editorInstance}
        providerRef={providerRef}
        docName={docName}
        theme={theme}
        user={user}
        loginUrl={loginUrl}
        isAuthenticated={isAuthenticated}
        canEdit={canEdit}
        isPublishing={publishStatus.active}
        publishingUserName={publishStatus.userName}
        onToggleTheme={toggleTheme}
        onOpenSettings={() => setSettingsOpen(true)}
        onOpenPublish={openPublishDialog}
        onOpenMobileToc={() => setTocSidebarOpen(true)}
        onOpenEmbedStudio={handleOpenEmbedStudio}
      />

      {!isEditorReady && (
        <div className="editor-loading-overlay">
          <span className="spinner spinner--lg" />
        </div>
      )}

      <div
        ref={editorContainerCallback}
        className={isEditorReady ? "editor-scroll editor-scroll--ready" : "editor-scroll"}
      >
        <div className="content-grid">
          <div className="content-grid__hero">
            <FrontmatterHero store={frontmatterStore} embedStore={embedStore} />
          </div>
          <div className="content-grid__toc">
            <div className="table-of-contents--sticky">
              <TableOfContents
                editor={editorInstance}
                scrollContainer={containerEl}
                autoCollapse={tocAutoCollapse}
              />
            </div>
          </div>
          <div className="content-grid__editor">
            <Editor
              docName={docName}
              user={user}
              editorRef={editorRef}
              onCommentStoreReady={onCommentStoreReady}
              onFrontmatterStoreReady={onFrontmatterStoreReady}
              onEmbedStoreReady={onEmbedStoreReady}
              onEmbedDataStoreReady={onEmbedDataStoreReady}
              onSettingsMapReady={onSettingsMapReady}
              onEditorReady={onEditorReady}
              onUndoManagerReady={setUndoManager}
              onProviderReady={onProviderReady}
              onAddComment={handleAddComment}
            />
          </div>
        </div>
        <EditorFooter store={frontmatterStore} editor={editorInstance} />
        <CommentMarginIcons
          editor={editorInstance}
          commentStore={commentStore}
          user={user}
        />
      </div>

      {chatOpen ? (
        <div className="chat-floating">
          <ChatPanel
            messages={agentChat.messages}
            isLoading={agentChat.isLoading}
            error={agentChat.error}
            input={agentChat.input}
            models={models}
            selectedModel={selectedModel}
            onModelChange={handleModelChange}
            onSend={agentChat.sendMessage}
            onSetInput={agentChat.setInput}
            onStop={agentChat.stop}
            onNewChat={() => agentChat.clearMessages()}
            onClose={() => setChatOpen(false)}
          />
        </div>
      ) : (
        <Tooltip title="AI Assistant" placement="right">
          <button
            className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => setChatOpen(true)}
            aria-label="AI Assistant"
          >
            <MessageCircle size={22} />
          </button>
        </Tooltip>
      )}

      <CommentDialog
        open={commentDialogOpen}
        onClose={() => setCommentDialogOpen(false)}
        onSubmit={handleCommentSubmit}
      />

      <SettingsDrawer
        open={settingsOpen}
        onClose={() => setSettingsOpen(false)}
        store={frontmatterStore}
        settingsMap={settingsMap}
      />

      {embedStudioSrc && (
        <EmbedStudio
          key={embedStudioSession ?? embedStudioSrc}
          src={embedStudioSrc}
          embedStore={embedStore}
          dataStore={embedDataStore}
          modelRef={modelRef}
          userId={chatUserId}
          onClose={() => {
            setEmbedStudioSrc(null);
            setEmbedStudioSession(null);
          }}
          onRename={handleEmbedRename}
          onSelectChart={(name) => {
            if (name === embedStudioSrc) return;
            setEmbedStudioSrc(name);
            // Rotate the session key so the studio remounts with a
            // fresh chat scope (loadMessages picks up the persisted
            // history of the newly-selected file).
            setEmbedStudioSession(
              `es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
            );
          }}
        />
      )}

      <MobileTocSidebar
        open={tocSidebarOpen}
        editor={editorInstance}
        scrollContainer={containerEl}
        autoCollapse={tocAutoCollapse}
        theme={theme}
        onClose={() => setTocSidebarOpen(false)}
        onToggleTheme={toggleTheme}
      />

      <PublishDialog
        ref={publishDialogRef}
        state={publishState}
        error={publishError}
        docName={docName}
        stageEvent={publishStage}
        onClose={closePublishDialog}
        onPublish={handlePublish}
      />
    </div>
  );
}
