// Docs module — Notion-like hierarchical pages with a block-based editor.
// Persistence: window.api.pages.* (REST). State lives entirely in the screen;
// we never mutate `tree` or `pages` in place — always replace via setState.
const { useState: useStateD, useEffect: useEffectD, useMemo: useMemoD,
        useRef: useRefD, useCallback: useCallbackD, useLayoutEffect: useLayoutEffectD } = React;

// ────────────────────────────────────────────────────────────────────────────────
// Block types and slash menu definition. Adding a new block type means:
//   1. add an entry to BLOCK_TYPES
//   2. handle render in <BlockRow>
//   3. (optional) add a markdown shortcut in MD_SHORTCUTS
// ────────────────────────────────────────────────────────────────────────────────
const BLOCK_TYPES = [
  { id: "paragraph",            label: "blk_text",     desc: "blk_text_desc",     keywords: "text paragraph p" },
  { id: "heading_1",            label: "blk_h1",       desc: "blk_h1_desc",       keywords: "heading h1 title" },
  { id: "heading_2",            label: "blk_h2",       desc: "blk_h2_desc",       keywords: "heading h2 subtitle" },
  { id: "heading_3",            label: "blk_h3",       desc: "blk_h3_desc",       keywords: "heading h3 section" },
  { id: "bulleted_list_item",   label: "blk_bullet",   desc: "blk_bullet_desc",   keywords: "bulleted list ul" },
  { id: "numbered_list_item",   label: "blk_numbered", desc: "blk_numbered_desc", keywords: "numbered list ol" },
  { id: "to_do",                label: "blk_todo",     desc: "blk_todo_desc",     keywords: "todo task checkbox" },
  { id: "quote",                label: "blk_quote",    desc: "blk_quote_desc",    keywords: "quote citation" },
  { id: "code",                 label: "blk_code",     desc: "blk_code_desc",     keywords: "code monospace pre" },
  { id: "divider",              label: "blk_divider",  desc: "blk_divider_desc",  keywords: "divider hr separator line" },
];

// Markdown shortcuts: when the user types one of these *as the only content* of
// a block followed by space, the block transforms into the target type and the
// trigger text is removed. Note: `divider` triggers on `---` without a space.
const MD_SHORTCUTS = [
  { trigger: "# ",    type: "heading_1" },
  { trigger: "## ",   type: "heading_2" },
  { trigger: "### ",  type: "heading_3" },
  { trigger: "- ",    type: "bulleted_list_item" },
  { trigger: "* ",    type: "bulleted_list_item" },
  { trigger: "1. ",   type: "numbered_list_item" },
  { trigger: "[] ",   type: "to_do" },
  { trigger: "[ ] ",  type: "to_do" },
  { trigger: "> ",    type: "quote" },
  { trigger: "``` ",  type: "code" },
];

const newBlockId = () =>
  (window.crypto?.randomUUID?.() ?? `blk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);

const makeBlock = (type = "paragraph", text = "") => ({
  id: newBlockId(),
  type,
  text,
  ...(type === "to_do" ? { checked: false } : {}),
});

// Parse stored JSON safely: never throw, always yield a valid blocks array so
// the editor can render *something* for corrupt content rather than blowing up.
function parseContent(raw) {
  if (!raw) return { blocks: [makeBlock()] };
  try {
    const obj = JSON.parse(raw);
    const blocks = Array.isArray(obj?.blocks) ? obj.blocks : [];
    return blocks.length ? { blocks } : { blocks: [makeBlock()] };
  } catch {
    return { blocks: [makeBlock()] };
  }
}

const serializeContent = (blocks) => JSON.stringify({ blocks });

// ────────────────────────────────────────────────────────────────────────────────
// Tree helpers — pages arrive as a flat list with parentId. Build the recursive
// children map once per render and walk it.
// ────────────────────────────────────────────────────────────────────────────────
function buildTree(pages) {
  const byParent = new Map();
  for (const p of pages) {
    const key = p.parentId ?? null;
    if (!byParent.has(key)) byParent.set(key, []);
    byParent.get(key).push(p);
  }
  for (const arr of byParent.values()) arr.sort((a, b) => a.position - b.position);
  return byParent;
}

// ────────────────────────────────────────────────────────────────────────────────
// Top-level screen
// ────────────────────────────────────────────────────────────────────────────────
window.DocsScreen = function DocsScreen({ t, lang, subTab, pathParts, setPathParts }) {
  const Icons = window.Icons;
  const toast = window.useToast();

  const effectiveTab = subTab || "spaces";

  // Tree of summaries (no body content), loaded once and refreshed on mutation.
  const [pages, setPages] = useStateD([]);
  const [loading, setLoading] = useStateD(true);
  const [error, setError] = useStateD(null);

  // The URL owns the open page id so the browser back button restores it.
  // localStorage is a fallback for the case where the user lands on /docs without
  // a page in the URL — typically the first time after switching modules.
  const urlPageId = (pathParts && pathParts[0]) || null;
  const [fallbackPageId, setFallbackPageId] = useStateD(() => {
    try { return localStorage.getItem("companyBrain.docs.lastPage") || null; }
    catch { return null; }
  });
  const selectedId = urlPageId || fallbackPageId;

  const persistSelection = (id, opts) => {
    setFallbackPageId(id || null);
    try {
      if (id) localStorage.setItem("companyBrain.docs.lastPage", id);
      else localStorage.removeItem("companyBrain.docs.lastPage");
    } catch { /* ignore */ }
    setPathParts(id ? [id] : [], opts);
  };

  const loadTree = useCallbackD(async () => {
    try {
      const data = await window.api.pages.tree();
      setPages(data || []);
      setError(null);
      return data || [];
    } catch (e) {
      setError(e.message || "Failed to load pages");
      return [];
    } finally {
      setLoading(false);
    }
  }, []);

  useEffectD(() => { loadTree(); }, [loadTree]);

  // If the selected page disappears from the tree (deleted, refreshed elsewhere),
  // pick a new one. Skip in trash mode — there the selection lives in TrashView.
  // Auto-corrections use replace so they don't pollute the back stack.
  useEffectD(() => {
    if (effectiveTab !== "spaces") return;
    if (!pages.length) { if (selectedId) persistSelection(null, { replace: true }); return; }
    if (!selectedId || !pages.some(p => p.id === selectedId)) {
      persistSelection(pages[0].id, { replace: true });
    }
  }, [pages, effectiveTab]); // eslint-disable-line react-hooks/exhaustive-deps

  if (effectiveTab === "trash") {
    return <TrashView t={t} lang={lang} reloadTree={loadTree} toast={toast} />;
  }
  if (effectiveTab === "shared" || effectiveTab === "templates") {
    const Icon = Icons.Sparkles;
    const titleKey = effectiveTab === "shared" ? "docs_section_shared" : "docs_section_templates";
    return (
      <div className="page">
        <window.PageHeader title={t(titleKey)} subtitle={t("docs_section_soon")} />
        <div className="empty" style={{ marginTop: 12, padding: "60px 24px" }}>
          <div className="empty-icon"><Icon /></div>
          <h3>{t("docs_section_soon")}</h3>
        </div>
      </div>
    );
  }

  return (
    <div className="page docs-page">
      <div className="docs-workspace">
        <PageTree
          t={t}
          pages={pages}
          loading={loading}
          error={error}
          selectedId={selectedId}
          onSelect={persistSelection}
          reload={loadTree}
          toast={toast}
        />
        <div className="docs-editor-wrap">
          {selectedId
            ? <PageEditor key={selectedId} pageId={selectedId} t={t} lang={lang} reloadTree={loadTree} toast={toast} />
            : <DocsEmptyState t={t} pages={pages} reload={loadTree} toast={toast} onCreated={persistSelection} />}
        </div>
      </div>
    </div>
  );
};

function DocsEmptyState({ t, pages, reload, toast, onCreated }) {
  const Icons = window.Icons;
  const create = async () => {
    try {
      const created = await window.api.pages.create({ title: "", icon: null, parentId: null });
      await reload();
      onCreated(created.id);
    } catch (e) {
      toast(e.message || "Failed to create page", "error");
    }
  };
  return (
    <div className="docs-empty">
      <div className="empty-icon"><Icons.Book /></div>
      <h3>{pages.length ? t("docs_pick_page") : t("docs_no_pages")}</h3>
      {!pages.length && <p>{t("docs_no_pages_sub")}</p>}
      <window.Btn variant="brand" icon={Icons.Plus} onClick={create}>{t("docs_new_page")}</window.Btn>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────────
// Page tree (left pane)
// ────────────────────────────────────────────────────────────────────────────────
function PageTree({ t, pages, loading, error, selectedId, onSelect, reload, toast }) {
  const Icons = window.Icons;

  const [expanded, setExpanded] = useStateD(() => {
    try {
      const raw = localStorage.getItem("companyBrain.docs.expanded");
      return raw ? new Set(JSON.parse(raw)) : new Set();
    } catch { return new Set(); }
  });
  const persistExpanded = (next) => {
    setExpanded(next);
    try { localStorage.setItem("companyBrain.docs.expanded", JSON.stringify([...next])); }
    catch { /* ignore */ }
  };
  const toggleExpand = (id) => {
    const next = new Set(expanded);
    next.has(id) ? next.delete(id) : next.add(id);
    persistExpanded(next);
  };

  const tree = useMemoD(() => buildTree(pages), [pages]);

  const createPage = async (parentId) => {
    try {
      const created = await window.api.pages.create({ title: "", icon: null, parentId });
      await reload();
      onSelect(created.id);
      if (parentId) {
        const next = new Set(expanded);
        next.add(parentId);
        persistExpanded(next);
      }
    } catch (e) {
      toast(e.message || "Failed to create page", "error");
    }
  };

  const renamePage = async (page) => {
    const next = window.prompt(t("docs_rename"), page.title || "");
    if (next === null) return;
    try {
      await window.api.pages.rename(page.id, { title: next });
      await reload();
    } catch (e) {
      toast(e.message || "Failed to rename", "error");
    }
  };

  const trashPage = async (page) => {
    try {
      await window.api.pages.sendToTrash(page.id);
      await reload();
    } catch (e) {
      toast(e.message || "Failed to delete", "error");
    }
  };

  const renderRow = (page, depth) => {
    const children = tree.get(page.id) || [];
    const hasChildren = children.length > 0;
    const isOpen = expanded.has(page.id);
    const isSel = page.id === selectedId;
    return (
      <React.Fragment key={page.id}>
        <div
          className={`docs-tree-row ${isSel ? "selected" : ""}`}
          style={{ paddingLeft: 8 + depth * 14 }}
          onClick={() => onSelect(page.id)}
        >
          <button
            type="button"
            className={`docs-tree-chevron ${hasChildren ? "" : "empty"} ${isOpen ? "open" : ""}`}
            onClick={(e) => { e.stopPropagation(); if (hasChildren) toggleExpand(page.id); }}
            aria-label={hasChildren ? (isOpen ? "Collapse" : "Expand") : ""}
            tabIndex={hasChildren ? 0 : -1}
          >
            {hasChildren ? <Icons.ChevronRight /> : null}
          </button>
          <span className="docs-tree-icon">
            {page.icon ? <span className="docs-tree-emoji">{page.icon}</span> : <Icons.FileText />}
          </span>
          <span className="docs-tree-title">{page.title || t("docs_untitled")}</span>
          <span className="docs-tree-actions">
            <button
              type="button"
              className="docs-tree-action"
              title={t("docs_new_subpage")}
              onClick={(e) => { e.stopPropagation(); createPage(page.id); }}
            >
              <Icons.Plus />
            </button>
            <RowMenu page={page} onRename={() => renamePage(page)} onTrash={() => trashPage(page)} t={t} />
          </span>
        </div>
        {hasChildren && isOpen && children.map((c) => renderRow(c, depth + 1))}
      </React.Fragment>
    );
  };

  const roots = tree.get(null) || [];

  return (
    <aside className="docs-tree">
      <div className="docs-tree-header">
        <button type="button" className="docs-tree-newbtn" onClick={() => createPage(null)}>
          <Icons.Plus /> <span>{t("docs_new_page")}</span>
        </button>
      </div>
      <div className="docs-tree-body">
        {loading
          ? <div className="docs-tree-status">…</div>
          : error
            ? <div className="docs-tree-status err">{error}</div>
            : roots.length === 0
              ? <div className="docs-tree-status">{t("docs_no_pages")}</div>
              : roots.map((p) => renderRow(p, 0))}
      </div>
    </aside>
  );
}

function RowMenu({ page, onRename, onTrash, t }) {
  const Icons = window.Icons;
  const [open, setOpen] = useStateD(false);
  const ref = useRefD(null);
  useEffectD(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);
  return (
    <span className="docs-row-menu" ref={ref}>
      <button
        type="button"
        className="docs-tree-action"
        title={page.title || t("docs_untitled")}
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
      >
        <Icons.MoreHorizontal />
      </button>
      {open && (
        <div className="docs-popover" onClick={(e) => e.stopPropagation()}>
          <button type="button" className="docs-popover-item" onClick={() => { setOpen(false); onRename(); }}>
            <Icons.Edit /> <span>{t("docs_rename")}</span>
          </button>
          <button type="button" className="docs-popover-item danger" onClick={() => { setOpen(false); onTrash(); }}>
            <Icons.Trash /> <span>{t("docs_send_to_trash")}</span>
          </button>
        </div>
      )}
    </span>
  );
}

// ────────────────────────────────────────────────────────────────────────────────
// Editor (right pane) — title input + block list with autosave.
// ────────────────────────────────────────────────────────────────────────────────
// Curated set so the picker stays focused on doc-relevant emoji. The picker
// also accepts a custom paste, so this isn't an exhaustive list.
const EMOJI_OPTIONS = [
  "📄","📝","📋","✅","☑️","📌","📎","🔖","📁","📂","🗂️","📚",
  "📖","📓","📔","📕","📗","📘","📙","🗒️","📰","📑",
  "🏠","🚀","🎯","🎨","💡","🔥","⭐","✨","💎","🛠️","⚙️","🔧",
  "📊","📈","📉","🧠","💬","🗣️","🎤","🎧","🔍","🔒","🔓","🔑",
  "🌱","🌿","🌳","🌍","🌐","🪄","⚡","☀️","🌙","🌟","🎉","🎁",
  "🍎","☕","🍕","🥤","🐛","🐞","🦄","🐱","🐶","🐼","🐧","🦊",
  "🚧","⚠️","ℹ️","❓","❗","✏️","🧩","🧪","🧰","🗓️","📅","⏰",
];

function EmojiPickerPopover({ open, onClose, anchorRef, value, onPick, onClear, t }) {
  const [custom, setCustom] = useStateD("");
  useEffectD(() => { if (open) setCustom(""); }, [open]);
  return (
    <window.Popover open={open} onClose={onClose} anchorRef={anchorRef} width={320} align="left">
      <div className="docs-emoji-picker">
        <div className="docs-emoji-grid">
          {EMOJI_OPTIONS.map((e) => (
            <button
              key={e}
              type="button"
              className={`docs-emoji-cell ${e === value ? "active" : ""}`}
              onClick={() => onPick(e)}
              title={e}
            >
              {e}
            </button>
          ))}
        </div>
        <div className="docs-emoji-foot">
          <input
            className="input docs-emoji-custom"
            value={custom}
            placeholder="Custom"
            maxLength={8}
            onChange={(e) => setCustom(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && custom.trim()) {
                e.preventDefault();
                onPick(custom.trim());
              }
            }}
          />
          <button
            type="button"
            className="btn"
            disabled={!custom.trim()}
            onClick={() => custom.trim() && onPick(custom.trim())}
          >
            {t("save")}
          </button>
          <button
            type="button"
            className="btn ghost"
            disabled={!value}
            onClick={onClear}
          >
            {t("docs_remove_icon")}
          </button>
        </div>
      </div>
    </window.Popover>
  );
}

function PageEditor({ pageId, t, lang, reloadTree, toast }) {
  const Icons = window.Icons;

  const [page, setPage] = useStateD(null);     // server snapshot (incl. content JSON)
  const [blocks, setBlocks] = useStateD(null); // editor model — array of blocks
  const [title, setTitle] = useStateD("");
  const [icon, setIcon] = useStateD(null);
  const [saveState, setSaveState] = useStateD("idle"); // idle | dirty | saving | saved | error
  const [iconOpen, setIconOpen] = useStateD(false);

  const dirtyRef = useRefD(false);
  const saveTimerRef = useRefD(null);
  const latestBlocksRef = useRefD(null);
  const focusBlockRef = useRefD(null); // { id, position } — block to focus on next render
  const iconBtnRef = useRefD(null);

  // Load page when id changes. The `key={selectedId}` on PageEditor in the parent
  // remounts the component, so this effect runs exactly once per page open.
  useEffectD(() => {
    let cancelled = false;
    (async () => {
      try {
        const data = await window.api.pages.get(pageId);
        if (cancelled) return;
        setPage(data);
        const parsed = parseContent(data.content);
        setBlocks(parsed.blocks);
        latestBlocksRef.current = parsed.blocks;
        setTitle(data.title || "");
        setIcon(data.icon || null);
        setSaveState("idle");
      } catch (e) {
        if (!cancelled) toast(e.message || "Failed to load page", "error");
      }
    })();
    return () => { cancelled = true; };
  }, [pageId]); // eslint-disable-line react-hooks/exhaustive-deps

  const flushSave = useCallbackD(async () => {
    if (!dirtyRef.current || latestBlocksRef.current == null) return;
    const payload = serializeContent(latestBlocksRef.current);
    setSaveState("saving");
    try {
      await window.api.pages.updateContent(pageId, { content: payload });
      dirtyRef.current = false;
      setSaveState("saved");
    } catch (e) {
      setSaveState("error");
      toast(e.message || "Save failed", "error");
    }
  }, [pageId, toast]);

  const scheduleSave = useCallbackD(() => {
    setSaveState("dirty");
    if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
    saveTimerRef.current = setTimeout(() => { saveTimerRef.current = null; flushSave(); }, 800);
  }, [flushSave]);

  // Flush pending changes on unmount or page switch — we don't want to lose the
  // last keystroke when the user clicks another page in the tree.
  useEffectD(() => {
    return () => {
      if (saveTimerRef.current) {
        clearTimeout(saveTimerRef.current);
        saveTimerRef.current = null;
        flushSave();
      }
    };
  }, [flushSave]);

  const updateBlocks = useCallbackD((updater) => {
    setBlocks((prev) => {
      const next = typeof updater === "function" ? updater(prev) : updater;
      latestBlocksRef.current = next;
      dirtyRef.current = true;
      return next;
    });
    scheduleSave();
  }, [scheduleSave]);

  const onTitleBlur = async () => {
    if (!page) return;
    const trimmed = (title || "").trim();
    if (trimmed === (page.title || "").trim()) return;
    try {
      await window.api.pages.rename(pageId, { title: trimmed });
      setPage((p) => p ? { ...p, title: trimmed } : p);
      reloadTree();
    } catch (e) {
      toast(e.message || "Failed to rename", "error");
    }
  };

  const persistIcon = async (next) => {
    try {
      await window.api.pages.setIcon(pageId, { icon: next || null });
      setIcon(next || null);
      reloadTree();
    } catch (e) {
      toast(e.message || "Failed to update icon", "error");
    }
  };

  if (!page || !blocks) {
    return <div className="docs-editor-loading">…</div>;
  }

  return (
    <div className="docs-editor">
      <div className="docs-editor-status" data-state={saveState}>
        {saveState === "saving" && t("docs_saving")}
        {saveState === "saved"  && t("docs_saved")}
        {saveState === "error"  && t("docs_save_failed")}
      </div>
      <div className="docs-page-head">
        <button
          ref={iconBtnRef}
          type="button"
          className="docs-icon-btn"
          onClick={() => setIconOpen((o) => !o)}
          title={t("docs_change_icon")}
        >
          {icon ? <span className="docs-icon-emoji">{icon}</span> : <Icons.Sparkles />}
        </button>
        <EmojiPickerPopover
          open={iconOpen}
          onClose={() => setIconOpen(false)}
          anchorRef={iconBtnRef}
          value={icon}
          onPick={async (e) => { setIconOpen(false); await persistIcon(e); }}
          onClear={async () => { setIconOpen(false); await persistIcon(null); }}
          t={t}
        />
        <input
          className="docs-title-input"
          value={title}
          placeholder={t("docs_title_placeholder")}
          onChange={(e) => setTitle(e.target.value)}
          onBlur={onTitleBlur}
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } }}
          maxLength={200}
        />
      </div>
      <BlockList
        blocks={blocks}
        onChange={updateBlocks}
        focusBlockRef={focusBlockRef}
        t={t}
      />
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────────
// BlockList — owns keyboard handling and the slash menu. Each block is a
// contentEditable div in plain-text mode (innerText only). We do NOT use
// `<textarea>` because we want vertical caret movement to flow between blocks
// and tighter visual control over heights.
// ────────────────────────────────────────────────────────────────────────────────
function BlockList({ blocks, onChange, focusBlockRef, t }) {
  const containerRef = useRefD(null);

  // Slash menu state. Anchored to caret coordinates; we filter items by `query`.
  const [slash, setSlash] = useStateD(null); // { blockId, x, y, query }
  const [slashIndex, setSlashIndex] = useStateD(0);

  const filteredBlockTypes = useMemoD(() => {
    if (!slash) return BLOCK_TYPES;
    const q = (slash.query || "").trim().toLowerCase();
    if (!q) return BLOCK_TYPES;
    return BLOCK_TYPES.filter((b) => {
      const haystack = `${t(b.label)} ${b.id} ${b.keywords}`.toLowerCase();
      return haystack.includes(q);
    });
  }, [slash, t]);

  useEffectD(() => { setSlashIndex(0); }, [slash?.query]); // eslint-disable-line react-hooks/exhaustive-deps

  // Focus a freshly created/transformed block on the next render.
  useLayoutEffectD(() => {
    const target = focusBlockRef.current;
    if (!target) return;
    const el = containerRef.current?.querySelector(`[data-block-id="${target.id}"] [data-editable="1"]`);
    if (!el) return;
    placeCaret(el, target.position === "start" ? 0 : (el.innerText || "").length);
    focusBlockRef.current = null;
  });

  const focusBlock = (id, position = "end") => {
    focusBlockRef.current = { id, position };
  };

  const insertAfter = (blockId, newType = "paragraph", text = "") => {
    const created = makeBlock(newType, text);
    onChange((prev) => {
      const idx = prev.findIndex((b) => b.id === blockId);
      if (idx < 0) return [...prev, created];
      const next = prev.slice();
      next.splice(idx + 1, 0, created);
      return next;
    });
    focusBlock(created.id, "start");
    return created.id;
  };

  const removeBlock = (blockId) => {
    onChange((prev) => {
      if (prev.length <= 1) return prev;
      const idx = prev.findIndex((b) => b.id === blockId);
      if (idx < 0) return prev;
      const next = prev.slice();
      next.splice(idx, 1);
      // Focus previous block (or first if removing the very first one).
      const target = next[Math.max(0, idx - 1)];
      if (target) focusBlock(target.id, "end");
      return next;
    });
  };

  const transformBlock = (blockId, type, text) => {
    onChange((prev) => prev.map((b) => b.id === blockId
      ? { ...makeBlock(type, text), id: b.id }
      : b));
    focusBlock(blockId, "end");
  };

  const updateBlockText = (blockId, text) => {
    onChange((prev) => prev.map((b) => b.id === blockId ? { ...b, text } : b));
  };

  const updateBlockField = (blockId, patch) => {
    onChange((prev) => prev.map((b) => b.id === blockId ? { ...b, ...patch } : b));
  };

  const moveCaret = (blockId, dir) => {
    const idx = blocks.findIndex((b) => b.id === blockId);
    if (idx < 0) return;
    if (dir === "up" && idx > 0) focusBlock(blocks[idx - 1].id, "end");
    if (dir === "down" && idx < blocks.length - 1) focusBlock(blocks[idx + 1].id, "start");
    // Trigger a re-render so the layoutEffect runs.
    onChange((prev) => prev.slice());
  };

  // Close the slash menu when caret leaves the trigger context.
  const closeSlash = () => setSlash(null);

  // The slash menu lives outside the block flow but is rendered inside the
  // editor container so it can position itself with absolute coords relative
  // to the page. Coordinates are computed from the caret rect at trigger time.
  const openSlashAtCaret = (block) => {
    const rect = caretRect();
    const containerRect = containerRef.current?.getBoundingClientRect();
    if (!rect || !containerRect) return;
    setSlash({
      blockId: block.id,
      x: rect.left - containerRect.left,
      y: rect.bottom - containerRect.top + 4,
      query: "",
    });
  };

  const applySlashType = (typeId) => {
    if (!slash) return;
    const targetType = typeId;
    const target = BLOCK_TYPES.find((b) => b.id === targetType);
    if (!target) return;
    if (targetType === "divider") {
      // Replace the current block with a divider AND insert a fresh paragraph
      // below so the caret has somewhere to go.
      const blockId = slash.blockId;
      onChange((prev) => {
        const idx = prev.findIndex((b) => b.id === blockId);
        if (idx < 0) return prev;
        const next = prev.slice();
        next.splice(idx, 1, makeBlock("divider"));
        const fresh = makeBlock("paragraph", "");
        next.splice(idx + 1, 0, fresh);
        focusBlockRef.current = { id: fresh.id, position: "start" };
        return next;
      });
    } else {
      transformBlock(slash.blockId, targetType, "");
    }
    closeSlash();
  };

  // ── Per-block keyboard handler ────────────────────────────────────────────
  const handleKeyDown = (block, e) => {
    // Slash menu intercept.
    if (slash && slash.blockId === block.id) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setSlashIndex((i) => Math.min(filteredBlockTypes.length - 1, i + 1));
        return;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        setSlashIndex((i) => Math.max(0, i - 1));
        return;
      }
      if (e.key === "Enter") {
        e.preventDefault();
        const target = filteredBlockTypes[slashIndex];
        if (target) applySlashType(target.id);
        return;
      }
      if (e.key === "Escape") {
        e.preventDefault();
        closeSlash();
        return;
      }
    }

    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      // List items split into a sibling of the same kind to keep the list flowing.
      // Empty list item turns back into a paragraph (Notion behavior).
      const txt = e.currentTarget.innerText || "";
      const isList = block.type === "bulleted_list_item" || block.type === "numbered_list_item" || block.type === "to_do";
      if (isList && txt.length === 0) {
        transformBlock(block.id, "paragraph", "");
        return;
      }
      const newType = isList ? block.type : "paragraph";
      insertAfter(block.id, newType, "");
      return;
    }

    if (e.key === "Backspace") {
      const el = e.currentTarget;
      const txt = el.innerText || "";
      // Caret at very start of the block + nothing selected → either fold back
      // into the previous block (joining text) or, for non-paragraph types,
      // demote to paragraph first.
      const sel = window.getSelection();
      const atStart = sel?.isCollapsed && caretOffset(el) === 0;
      if (!atStart) return;

      if (block.type !== "paragraph" && block.type !== "divider") {
        e.preventDefault();
        transformBlock(block.id, "paragraph", txt);
        return;
      }
      // Plain paragraph at start: merge with previous if any.
      if (txt.length === 0) {
        e.preventDefault();
        removeBlock(block.id);
      }
      // If there's text we still merge into the previous block.
      else {
        const idx = blocks.findIndex((b) => b.id === block.id);
        if (idx > 0) {
          e.preventDefault();
          const prev = blocks[idx - 1];
          if (prev.type === "divider") {
            removeBlock(prev.id);
            return;
          }
          const merged = (prev.text || "") + txt;
          onChange((arr) => {
            const out = arr.slice();
            out[idx - 1] = { ...prev, text: merged };
            out.splice(idx, 1);
            return out;
          });
          // Position caret at the join point of the previous block.
          focusBlockRef.current = { id: prev.id, position: "end" };
        }
      }
      return;
    }

    if (e.key === "ArrowUp") {
      const el = e.currentTarget;
      const sel = window.getSelection();
      if (sel?.isCollapsed && isCaretOnFirstLine(el)) {
        e.preventDefault();
        moveCaret(block.id, "up");
      }
      return;
    }
    if (e.key === "ArrowDown") {
      const el = e.currentTarget;
      const sel = window.getSelection();
      if (sel?.isCollapsed && isCaretOnLastLine(el)) {
        e.preventDefault();
        moveCaret(block.id, "down");
      }
      return;
    }

    if (e.key === "/") {
      // Open slash menu only when at the start of an empty (or about-to-be-typed)
      // block. Notion is more lenient (anywhere in the line), but for MVP we
      // restrict to start-of-block to avoid tangling with quote/list prefixes.
      const el = e.currentTarget;
      const txt = el.innerText || "";
      if (txt.length === 0) {
        // Allow the keypress to insert "/" so the user sees the typed character;
        // we open the menu in the input handler with the empty query.
        setTimeout(() => openSlashAtCaret(block), 0);
      }
      return;
    }
  };

  const handleInput = (block, e) => {
    const el = e.currentTarget;
    const text = el.innerText || "";

    // Slash menu: while open, the query is the text after the leading "/" in
    // the current block. Close as soon as the leading "/" is gone.
    if (slash && slash.blockId === block.id) {
      if (text.startsWith("/")) {
        const q = text.slice(1);
        if (q !== slash.query) setSlash({ ...slash, query: q });
      } else {
        closeSlash();
      }
    } else if (text.startsWith("/") && (block.type === "paragraph" || block.type === "to_do")) {
      // User just typed "/" (or fast-typed "/abc") — open the menu and seed query.
      const rect = caretRect();
      const containerRect = containerRef.current?.getBoundingClientRect();
      if (rect && containerRect) {
        setSlash({
          blockId: block.id,
          x: rect.left - containerRect.left,
          y: rect.bottom - containerRect.top + 4,
          query: text.slice(1),
        });
      }
    }

    // Markdown shortcut: trigger on space following one of our patterns. We
    // detect by inspecting the leading text. Divider is handled separately.
    if (block.type === "paragraph" || block.type === "to_do") {
      // Divider: typing exactly "---" turns into a divider + new paragraph.
      if (text === "---") {
        // Defer to next tick so contentEditable settles, then transform.
        const blockId = block.id;
        setTimeout(() => {
          onChange((prev) => {
            const idx = prev.findIndex((b) => b.id === blockId);
            if (idx < 0) return prev;
            const next = prev.slice();
            next.splice(idx, 1, makeBlock("divider"));
            const fresh = makeBlock("paragraph", "");
            next.splice(idx + 1, 0, fresh);
            focusBlockRef.current = { id: fresh.id, position: "start" };
            return next;
          });
        }, 0);
        return;
      }
      for (const sc of MD_SHORTCUTS) {
        if (text === sc.trigger || text === sc.trigger.trimEnd() + " ") {
          // Only paragraphs convert via shortcut — to_do items keep their type.
          if (block.type !== "paragraph") continue;
          transformBlock(block.id, sc.type, "");
          return;
        }
      }
    }

    // Normal path: just persist the new text.
    if (text !== (block.text || "")) {
      updateBlockText(block.id, text);
    }
  };

  // Compute consecutive numbering for numbered_list_item runs.
  const numbering = useMemoD(() => {
    const out = {};
    let n = 0;
    for (const b of blocks) {
      if (b.type === "numbered_list_item") { n += 1; out[b.id] = n; }
      else { n = 0; }
    }
    return out;
  }, [blocks]);

  // Click on empty area below blocks: focus the last editable block, or
  // append a fresh paragraph if the last block is a divider. Mirrors Notion.
  const handleContainerClick = (e) => {
    if (e.target !== containerRef.current) return; // only on the gutter
    const last = blocks[blocks.length - 1];
    if (!last || last.type === "divider") {
      insertAfter(last?.id, "paragraph", "");
      return;
    }
    const el = containerRef.current?.querySelector(`[data-block-id="${last.id}"] [data-editable="1"]`);
    if (el) placeCaret(el, (el.innerText || "").length);
  };

  return (
    <div className="docs-blocklist" ref={containerRef} onClick={handleContainerClick}>
      {blocks.map((b) => (
        <BlockRow
          key={b.id}
          block={b}
          number={numbering[b.id]}
          onKeyDown={(e) => handleKeyDown(b, e)}
          onInput={(e) => handleInput(b, e)}
          onChangeField={(patch) => updateBlockField(b.id, patch)}
          onRemove={() => removeBlock(b.id)}
          t={t}
        />
      ))}
      {slash && (
        <SlashMenu
          x={slash.x}
          y={slash.y}
          items={filteredBlockTypes}
          activeIndex={slashIndex}
          onPick={(typeId) => applySlashType(typeId)}
          onHover={setSlashIndex}
          t={t}
        />
      )}
    </div>
  );
}

function BlockRow({ block, number, onKeyDown, onInput, onChangeField, onRemove, t }) {
  // Each editable surface uses contentEditable with suppressContentEditableWarning
  // and is uncontrolled — React never overwrites the DOM after mount, the
  // user's keystrokes drive innerText directly. We only re-mount the surface
  // when block.type changes (different placeholder / wrapper class).
  const editable = (
    <EditableSurface
      blockId={block.id}
      blockType={block.type}
      initialText={block.text || ""}
      placeholder={t("docs_block_placeholder")}
      onKeyDown={onKeyDown}
      onInput={onInput}
    />
  );

  if (block.type === "divider") {
    return (
      <div className="docs-block" data-block-id={block.id} data-block-type="divider">
        <hr className="bk-divider" />
      </div>
    );
  }

  if (block.type === "to_do") {
    return (
      <div className="docs-block bk-todo-row" data-block-id={block.id} data-block-type="to_do">
        <input
          type="checkbox"
          className="bk-todo-check"
          checked={!!block.checked}
          onChange={(e) => onChangeField({ checked: e.target.checked })}
          // Stop the click from propagating into the editable surface focus.
          onClick={(e) => e.stopPropagation()}
        />
        <div className={`bk-todo-text ${block.checked ? "checked" : ""}`}>{editable}</div>
      </div>
    );
  }

  if (block.type === "bulleted_list_item") {
    return (
      <div className="docs-block bk-li bk-li-bullet" data-block-id={block.id} data-block-type="bulleted_list_item">
        <span className="bk-li-marker" aria-hidden>•</span>
        <div className="bk-li-text">{editable}</div>
      </div>
    );
  }
  if (block.type === "numbered_list_item") {
    return (
      <div className="docs-block bk-li bk-li-num" data-block-id={block.id} data-block-type="numbered_list_item">
        <span className="bk-li-marker" aria-hidden>{(number ?? 1)}.</span>
        <div className="bk-li-text">{editable}</div>
      </div>
    );
  }
  if (block.type === "quote") {
    return (
      <div className="docs-block bk-quote" data-block-id={block.id} data-block-type="quote">
        {editable}
      </div>
    );
  }
  if (block.type === "code") {
    return (
      <div className="docs-block bk-code" data-block-id={block.id} data-block-type="code">
        {editable}
      </div>
    );
  }
  if (block.type === "heading_1") {
    return <div className="docs-block bk-h1" data-block-id={block.id} data-block-type="heading_1">{editable}</div>;
  }
  if (block.type === "heading_2") {
    return <div className="docs-block bk-h2" data-block-id={block.id} data-block-type="heading_2">{editable}</div>;
  }
  if (block.type === "heading_3") {
    return <div className="docs-block bk-h3" data-block-id={block.id} data-block-type="heading_3">{editable}</div>;
  }
  return <div className="docs-block bk-p" data-block-id={block.id} data-block-type="paragraph">{editable}</div>;
}

// EditableSurface keeps the DOM uncontrolled. We seed innerText on mount and
// when `blockId` changes (e.g. block transformed type or got replaced); after
// that the user owns it. React re-renders are essentially no-ops for this node.
function EditableSurface({ blockId, blockType, initialText, placeholder, onKeyDown, onInput }) {
  const ref = useRefD(null);
  // Sync DOM text whenever the block id or type changes — this catches both
  // the initial mount and when transformBlock() reuses the same block id but
  // resets the text to "" via a new makeBlock call.
  useLayoutEffectD(() => {
    if (!ref.current) return;
    if ((ref.current.innerText || "") !== (initialText || "")) {
      ref.current.innerText = initialText || "";
    }
  }, [blockId, blockType]); // intentionally not on initialText — would clobber user typing

  return (
    <div
      ref={ref}
      className="docs-editable"
      contentEditable
      suppressContentEditableWarning
      data-editable="1"
      data-placeholder={placeholder}
      onKeyDown={onKeyDown}
      onInput={onInput}
      spellCheck
    />
  );
}

function SlashMenu({ x, y, items, activeIndex, onPick, onHover, t }) {
  if (!items.length) return null;
  return (
    <div className="docs-slashmenu" style={{ left: x, top: y }}>
      {items.map((b, i) => (
        <div
          key={b.id}
          className={`docs-slash-item ${i === activeIndex ? "active" : ""}`}
          onMouseDown={(e) => { e.preventDefault(); onPick(b.id); }}
          onMouseEnter={() => onHover(i)}
        >
          <div className="docs-slash-title">{t(b.label)}</div>
          <div className="docs-slash-desc">{t(b.desc)}</div>
        </div>
      ))}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────────
// Trash view
// ────────────────────────────────────────────────────────────────────────────────
function TrashView({ t, lang, reloadTree, toast }) {
  const Icons = window.Icons;
  const [items, setItems] = useStateD([]);
  const [loading, setLoading] = useStateD(true);

  const reload = async () => {
    setLoading(true);
    try { setItems(await window.api.pages.trash() || []); }
    catch (e) { toast(e.message || "Failed to load trash", "error"); }
    finally { setLoading(false); }
  };
  useEffectD(() => { reload(); }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const restore = async (id) => {
    try { await window.api.pages.restore(id); await reload(); reloadTree(); }
    catch (e) { toast(e.message || "Restore failed", "error"); }
  };
  const purge = async (id) => {
    if (!window.confirm(t("docs_delete_forever_confirm"))) return;
    try { await window.api.pages.deleteForever(id); await reload(); reloadTree(); }
    catch (e) { toast(e.message || "Delete failed", "error"); }
  };

  return (
    <div className="page">
      <window.PageHeader title={t("docs_title")} subtitle={t("docs_send_to_trash")} />
      {loading ? (
        <div className="docs-trash-status">…</div>
      ) : items.length === 0 ? (
        <div className="empty" style={{ marginTop: 12, padding: "60px 24px" }}>
          <div className="empty-icon"><Icons.Trash /></div>
          <h3>{t("docs_trash_empty")}</h3>
          <p>{t("docs_trash_empty_sub")}</p>
        </div>
      ) : (
        <div className="docs-trash-list">
          {items.map((p) => (
            <div key={p.id} className="docs-trash-row">
              <span className="docs-tree-icon">
                {p.icon ? <span className="docs-tree-emoji">{p.icon}</span> : <Icons.FileText />}
              </span>
              <span className="docs-trash-title">{p.title || t("docs_untitled")}</span>
              <div className="docs-trash-actions">
                <button type="button" className="btn ghost" onClick={() => restore(p.id)}>
                  <Icons.ArchiveRestore /> <span>{t("docs_restore")}</span>
                </button>
                <button type="button" className="btn ghost danger" onClick={() => purge(p.id)}>
                  <Icons.Trash /> <span>{t("docs_delete_forever")}</span>
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────────
// Caret / selection helpers
// ────────────────────────────────────────────────────────────────────────────────
function placeCaret(el, offset) {
  el.focus();
  const range = document.createRange();
  const sel = window.getSelection();
  // Walk text nodes and stop when offset is reached. If the element is empty,
  // place the caret inside the empty element directly.
  const node = firstTextNodeAtOffset(el, offset);
  if (node) {
    range.setStart(node.node, node.offset);
    range.collapse(true);
  } else {
    range.selectNodeContents(el);
    range.collapse(true);
  }
  sel.removeAllRanges();
  sel.addRange(range);
}

function firstTextNodeAtOffset(root, target) {
  let remaining = target;
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
  let node = walker.nextNode();
  while (node) {
    const len = node.nodeValue.length;
    if (remaining <= len) return { node, offset: remaining };
    remaining -= len;
    node = walker.nextNode();
  }
  return null;
}

function caretOffset(root) {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return 0;
  const range = sel.getRangeAt(0).cloneRange();
  range.setStart(root, 0);
  return range.toString().length;
}

function caretRect() {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return null;
  const range = sel.getRangeAt(0).cloneRange();
  let rect = range.getBoundingClientRect();
  // If the range is collapsed inside an empty element, getBoundingClientRect
  // returns zeros; fall back to inserting a temp marker.
  if (rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0) {
    const span = document.createElement("span");
    span.appendChild(document.createTextNode("​"));
    range.insertNode(span);
    rect = span.getBoundingClientRect();
    span.parentNode.removeChild(span);
  }
  return rect;
}

function isCaretOnFirstLine(el) {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return true;
  const r = sel.getRangeAt(0).cloneRange();
  const caret = r.getBoundingClientRect();
  const top = el.getBoundingClientRect().top;
  return caret.top - top < 6; // line-height tolerance
}

function isCaretOnLastLine(el) {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return true;
  const r = sel.getRangeAt(0).cloneRange();
  const caret = r.getBoundingClientRect();
  const bottom = el.getBoundingClientRect().bottom;
  return bottom - caret.bottom < 6;
}

Object.assign(window, { DocsScreen: window.DocsScreen });
