import FormData from "form-data";
import axios from "axios";
import { basename, extname } from "path";
import { filterWorkspace, filterSpace, filterPage, filterComment, filterSearchResult, } from "./lib/filters.js";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
import { updatePageContentRealtime, replacePageContent, markdownToProseMirror, mutatePageContent, buildCollabWsUrl, assertYjsEncodable, applyDocToFragment, } from "./lib/collaboration.js";
import { footnoteWarningsField } from "./lib/footnote-analyze.js";
import { buildPageTree } from "./lib/tree.js";
import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "./lib/markdown-document.js";
import { replaceNodeById, deleteNodeById, assertUnambiguousMatch, insertNodeRelative, buildOutline, getNodeByRef, readTable, insertTableRow, deleteTableRow, updateTableCell, } from "./lib/node-ops.js";
import { withPageLock } from "./lib/page-lock.js";
import { applyTextEdits, } from "./lib/json-edit.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
import { diffDocs, summarizeChange } from "./lib/diff.js";
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, } from "./lib/transforms.js";
import vm from "node:vm";
// Supported image types, kept as two lookup tables so both a local file
// extension and a remote Content-Type can be mapped to the same canonical set.
const EXT_TO_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".svg": "image/svg+xml",
};
const MIME_TO_EXT = {
    "image/png": ".png",
    "image/jpeg": ".jpg",
    "image/gif": ".gif",
    "image/webp": ".webp",
    "image/svg+xml": ".svg",
};
export class DocmostClient {
    client;
    token = null;
    apiUrl;
    // email/password are only set on the service-account (credentials) variant;
    // null on the getToken variant (where there are no credentials to log in with).
    email = null;
    password = null;
    // Per-user token provider. When set, login() calls it to obtain a BARE access
    // JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
    getTokenFn = null;
    // Optional collab-token provider. When set, getCollabTokenWithReauth() returns
    // its token instead of calling POST /auth/collab-token; on a 401/403 it is
    // re-invoked once. Used by the internal agent to carry signed provenance.
    getCollabTokenFn = null;
    // In-flight login dedup: when the token expires, the 401 interceptor,
    // ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
    // can all call login() at once. Memoizing a single promise collapses that
    // thundering herd into ONE /auth/login request that everyone awaits.
    loginPromise = null;
    constructor(configOrBaseURL, email, password) {
        // Normalize the legacy positional form into the object union.
        const config = typeof configOrBaseURL === "string"
            ? { apiUrl: configOrBaseURL, email: email, password: password }
            : configOrBaseURL;
        this.apiUrl = config.apiUrl;
        if ("getToken" in config) {
            // Token variant: carry the user's JWT via getToken; no credentials, so
            // login() must never call performLogin (there is nothing to log in with).
            this.getTokenFn = config.getToken;
        }
        else {
            // Service-account variant: behaves exactly as before (performLogin).
            this.email = config.email;
            this.password = config.password;
        }
        // Optional, available to both variants. When present, content mutations get
        // their collab token from here instead of POST /auth/collab-token.
        if (config.getCollabToken) {
            this.getCollabTokenFn = config.getCollabToken;
        }
        this.client = axios.create({
            baseURL: this.apiUrl,
            // Default request timeout so a hung connection cannot wedge a per-page
            // lock or block the server indefinitely. Multipart uploads override this
            // with a longer per-request timeout.
            timeout: 30000,
            headers: {
                "Content-Type": "application/json",
            },
        });
        // Re-authenticate transparently on a 401/403 once: the JWT authToken can
        // expire while the server is long-running, after which every cached-token
        // request would otherwise fail until a manual restart. On such a response,
        // clear the stale token, perform a fresh login, and replay the original
        // request exactly once (guarded by config._retry to avoid infinite loops;
        // the login request itself is never retried).
        this.client.interceptors.response.use((response) => response, async (error) => {
            const config = error.config;
            const status = error.response?.status;
            const isAuthError = status === 401 || status === 403;
            const isLoginRequest = typeof config?.url === "string" && config.url.includes("/auth/login");
            if (config && isAuthError && !config._retry && !isLoginRequest) {
                config._retry = true;
                // Drop the stale token + Authorization header before re-login.
                this.token = null;
                delete this.client.defaults.headers.common["Authorization"];
                try {
                    await this.login();
                }
                catch (loginError) {
                    // Re-login failed: surface the original error to the caller.
                    return Promise.reject(error);
                }
                // Re-issue the original request with the freshly minted Bearer token.
                // Read it from the default header that login() just set, not from
                // this.token, to avoid a theoretical "Bearer null" if this.token was
                // cleared between login() resolving and this point.
                config.headers = config.headers || {};
                config.headers["Authorization"] =
                    this.client.defaults.headers.common["Authorization"];
                return this.client.request(config);
            }
            return Promise.reject(error);
        });
    }
    /** Application base URL (API URL without the /api suffix). */
    get appUrl() {
        return this.apiUrl.replace(/\/api\/?$/, "");
    }
    async login() {
        // Reuse an in-flight login if one is already running so concurrent callers
        // share a single token fetch instead of each issuing their own.
        if (!this.loginPromise) {
            // Token variant: re-fetch a BARE JWT via getToken() (there are no
            // credentials to log in with — on a 401/403 the interceptor below calls
            // login() again, which re-invokes getToken()). Credentials variant:
            // performLogin against /auth/login exactly as before.
            const fetchToken = this.getTokenFn
                ? this.getTokenFn()
                : performLogin(this.apiUrl, this.email, this.password);
            this.loginPromise = fetchToken
                .then((token) => {
                // Guard against an empty/invalid token (e.g. a getToken provider that
                // resolves to "" or null): without this an empty token would set a
                // literal "Authorization: Bearer null"/"Bearer " header and every
                // request would 401 with a confusing error. Fail loudly instead.
                if (typeof token !== "string" || token.length === 0) {
                    throw new Error("getToken returned an empty token");
                }
                this.token = token;
                this.client.defaults.headers.common["Authorization"] =
                    `Bearer ${token}`;
            })
                .finally(() => {
                this.loginPromise = null;
            });
        }
        return this.loginPromise;
    }
    async ensureAuthenticated() {
        if (!this.token) {
            await this.login();
        }
    }
    /**
     * Fetch a collaboration token, transparently re-authenticating once on a
     * 401/403. getCollabToken() uses bare axios internally, so it is NOT covered
     * by this.client's response interceptor; this helper replicates that
     * behaviour for collab-token requests: ensure a token, try once, and on an
     * expired-token auth error perform a fresh login and retry exactly once.
     */
    async getCollabTokenWithReauth() {
        // Collab-token PROVIDER path: when a getCollabToken provider was supplied
        // (the internal agent's provenance collab token), use it instead of the
        // REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
        // signed token expired between content mutations in a long agent turn).
        if (this.getCollabTokenFn) {
            try {
                const token = await this.getCollabTokenFn();
                if (typeof token !== "string" || token.length === 0) {
                    throw new Error("getCollabToken returned an empty token");
                }
                return token;
            }
            catch (e) {
                const axiosStatus = axios.isAxiosError(e)
                    ? e.response?.status
                    : undefined;
                const attachedStatus = e?.status;
                const isAuthError = axiosStatus === 401 ||
                    axiosStatus === 403 ||
                    attachedStatus === 401 ||
                    attachedStatus === 403;
                if (isAuthError) {
                    const token = await this.getCollabTokenFn();
                    if (typeof token !== "string" || token.length === 0) {
                        throw new Error("getCollabToken returned an empty token");
                    }
                    return token;
                }
                throw e;
            }
        }
        await this.ensureAuthenticated();
        try {
            return await getCollabToken(this.apiUrl, this.token);
        }
        catch (e) {
            // getCollabToken wraps the AxiosError in a plain Error but attaches the
            // HTTP status as `.status`, so detect an auth failure via either the raw
            // AxiosError shape OR the attached status.
            const axiosStatus = axios.isAxiosError(e)
                ? e.response?.status
                : undefined;
            const attachedStatus = e?.status;
            const isAuthError = axiosStatus === 401 ||
                axiosStatus === 403 ||
                attachedStatus === 401 ||
                attachedStatus === 403;
            if (isAuthError) {
                await this.login();
                return await getCollabToken(this.apiUrl, this.token);
            }
            throw e;
        }
    }
    /**
     * Connect to the collaboration websocket, read the live doc, apply
     * `transform`, write the result, and wait for the server to persist it —
     * WITHOUT acquiring the per-page lock.
     *
     * This mirrors collaboration.mutatePageContent EXCEPT that it does not call
     * withPageLock. It exists solely so replaceImage can hold ONE withPageLock
     * across its scan -> upload -> write sequence: the per-page mutex is NOT
     * reentrant, so calling the normal (self-locking) mutatePageContent inside an
     * outer withPageLock for the same pageId would deadlock. The caller MUST hold
     * the page lock for the whole operation; this helper assumes that invariant.
     *
     * `transform` receives the live ProseMirror doc and returns the NEW full doc
     * to write, or `null` to abort with no write. Errors thrown by `transform`
     * propagate to the caller.
     *
     * Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so
     * every content mutator (including replaceImage) can return a verifiable
     * change report. The report is computed AFTER the atomic read->write and
     * never throws.
     */
    mutateLiveContentUnlocked(pageId, collabToken, transform) {
        const CONNECT_TIMEOUT_MS = 25000;
        const PERSIST_TIMEOUT_MS = 20000;
        const ydoc = new Y.Doc();
        const wsUrl = buildCollabWsUrl(this.apiUrl);
        return new Promise((resolve, reject) => {
            let provider;
            let applied = false; // onSynced may fire again on reconnect — apply once.
            let settled = false;
            let connectionLost = false;
            let connectTimer;
            let persistTimer;
            let unsyncedHandler;
            // The verifiable result resolved on every success/abort path. Set on abort
            // (no-op report) and after a real write (computed change report).
            let mutationResult;
            const cleanup = () => {
                if (connectTimer)
                    clearTimeout(connectTimer);
                if (persistTimer)
                    clearTimeout(persistTimer);
                if (provider) {
                    if (unsyncedHandler) {
                        try {
                            provider.off("unsyncedChanges", unsyncedHandler);
                        }
                        catch (err) { }
                    }
                    try {
                        provider.destroy();
                    }
                    catch (err) { }
                }
            };
            const finish = (err, value) => {
                if (settled)
                    return;
                settled = true;
                cleanup();
                if (err)
                    reject(err);
                else
                    resolve(value);
            };
            connectTimer = setTimeout(() => {
                finish(new Error("Connection timeout to collaboration server"));
            }, CONNECT_TIMEOUT_MS);
            const waitForPersistence = () => {
                if (settled)
                    return;
                if (!provider) {
                    finish(new Error("collab provider gone before persistence"));
                    return;
                }
                if (provider.unsyncedChanges === 0) {
                    finish(null, mutationResult);
                    return;
                }
                persistTimer = setTimeout(() => {
                    finish(new Error("Timeout waiting for collaboration server to persist the update"));
                }, PERSIST_TIMEOUT_MS);
                unsyncedHandler = (data) => {
                    if (data.number === 0 && !connectionLost) {
                        finish(null, mutationResult);
                    }
                };
                provider.on("unsyncedChanges", unsyncedHandler);
            };
            provider = new HocuspocusProvider({
                url: wsUrl,
                name: `page.${pageId}`,
                document: ydoc,
                token: collabToken,
                // @ts-ignore - Required for Node.js environment
                WebSocketPolyfill: WebSocket,
                onDisconnect: () => {
                    connectionLost = true;
                    finish(new Error("Collaboration connection closed before the update was persisted/synced"));
                },
                onClose: () => {
                    connectionLost = true;
                    finish(new Error("Collaboration connection closed before the update was persisted/synced"));
                },
                onSynced: () => {
                    if (applied || settled)
                        return;
                    applied = true;
                    // CRITICAL: keep everything between reading and writing the live doc
                    // synchronous (no await) so no remote update can interleave.
                    let newDoc;
                    let beforeDoc;
                    try {
                        let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
                        if (!liveDoc ||
                            typeof liveDoc !== "object" ||
                            !Array.isArray(liveDoc.content)) {
                            liveDoc = { type: "doc", content: [] };
                        }
                        // Snapshot the before-doc for the change report (safe deep clone).
                        beforeDoc = JSON.parse(JSON.stringify(liveDoc));
                        newDoc = transform(liveDoc);
                        if (newDoc == null) {
                            // Transform aborted — write nothing, return the live doc with a
                            // no-op change report.
                            mutationResult = {
                                doc: liveDoc,
                                verify: {
                                    changed: false,
                                    textInserted: 0,
                                    textDeleted: 0,
                                    blocksChanged: 0,
                                    marks: {},
                                    summary: "no changes (transform aborted)",
                                },
                            };
                            finish(null, mutationResult);
                            return;
                        }
                        // Structural diff into the live fragment (issue #152), mirroring
                        // the main write path: preserves the Yjs ids of unchanged nodes so
                        // an open editor's cursor is not yanked to the end of the document.
                        // The previous destructive rewrite (delete-all + applyUpdate of a
                        // fresh Y.Doc) discarded every node id, so replaceImage — the only
                        // caller of this method — still reproduced the #152 cursor jump
                        // (#164). applyDocToFragment runs its own atomic `transact`.
                        applyDocToFragment(ydoc, newDoc);
                    }
                    catch (e) {
                        finish(e instanceof Error ? e : new Error(String(e)));
                        return;
                    }
                    // Compute the verifiable change report AFTER the transact write: it
                    // only needs the JSON before/after, so it cannot affect the atomic
                    // read->write window, and summarizeChange never throws.
                    mutationResult = {
                        doc: newDoc,
                        verify: summarizeChange(beforeDoc, newDoc),
                    };
                    waitForPersistence();
                },
                onAuthenticationFailed: () => {
                    finish(new Error("Authentication failed for collaboration connection"));
                },
            });
        });
    }
    /**
     * Generic pagination handler for Docmost API endpoints
     */
    async paginateAll(endpoint, basePayload = {}, limit = 100) {
        await this.ensureAuthenticated();
        const clampedLimit = Math.max(1, Math.min(100, limit));
        // Hard ceiling on the number of pages to fetch: guards against a server
        // that returns a perpetually-true hasNextPage (which would otherwise loop
        // forever and accumulate duplicates).
        const MAX_PAGES = 50;
        let page = 1;
        let allItems = [];
        let hasNextPage = true;
        while (hasNextPage && page <= MAX_PAGES) {
            const response = await this.client.post(endpoint, {
                ...basePayload,
                limit: clampedLimit,
                page,
            });
            const data = response.data;
            const items = data.data?.items || data.items || [];
            const meta = data.data?.meta || data.meta;
            allItems = allItems.concat(items);
            // Stop if the page is empty or shorter than the requested size: a full
            // page worth of items is the only situation where another page can exist,
            // so this defends against a stuck hasNextPage flag in addition to it.
            if (items.length === 0 || items.length < clampedLimit) {
                break;
            }
            hasNextPage = meta?.hasNextPage || false;
            page++;
        }
        // If the loop stopped because it hit the MAX_PAGES ceiling while the server
        // still reported more results (hasNextPage true and the last page was
        // full), the result set is truncated — warn so the caller is not silently
        // handed an incomplete list.
        if (hasNextPage && page > MAX_PAGES) {
            console.warn(`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`);
        }
        return allItems;
    }
    async getWorkspace() {
        await this.ensureAuthenticated();
        const response = await this.client.post("/workspace/info", {});
        return {
            data: filterWorkspace(response.data?.data ?? response.data),
            success: response.data.success,
        };
    }
    async getSpaces() {
        const spaces = await this.paginateAll("/spaces", {});
        return spaces.map((space) => filterSpace(space));
    }
    /**
     * List pages in one of two modes.
     *
     * Default (`tree` false): most recent pages by updatedAt (descending),
     * bounded. Fetching the whole space can exceed MCP response/time limits on
     * large instances, so a single bounded page of results is returned (default
     * 50, max 100) via the `/pages/recent` feed.
     *
     * Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each
     * node has a `children` array). This mode REQUIRES `spaceId` (a page tree is
     * scoped to one space) and IGNORES `limit` — the whole hierarchy is returned.
     * It walks the sidebar tree via `enumerateSpacePages`, which performs N
     * sidebar requests and is bounded by that method's 10000-node cap (and skips
     * soft-deleted pages server-side).
     */
    async listPages(spaceId, limit = 50, tree = false) {
        await this.ensureAuthenticated();
        if (tree) {
            if (!spaceId) {
                throw new Error("list_pages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.");
            }
            const nodes = await this.enumerateSpacePages(spaceId);
            return buildPageTree(nodes);
        }
        const clampedLimit = Math.max(1, Math.min(100, limit));
        const payload = { limit: clampedLimit, page: 1 };
        if (spaceId)
            payload.spaceId = spaceId;
        const response = await this.client.post("/pages/recent", payload);
        const data = response.data;
        const items = data.data?.items || data.items || [];
        return items.map((page) => filterPage(page));
    }
    /**
     * List sidebar pages for a space. With no pageId the request returns the
     * space ROOT pages; with a pageId it returns the direct CHILDREN of that
     * page. pageId is therefore optional and is only included in the POST body
     * when provided (an empty/undefined pageId would otherwise change the
     * semantics on the server).
     */
    async listSidebarPages(spaceId, pageId) {
        await this.ensureAuthenticated();
        // Paginate: the endpoint returns server-paged children, so posting only
        // { page: 1 } silently dropped every child beyond the first page. Loop on
        // meta.hasNextPage (with a MAX_PAGES ceiling like paginateAll, guarding
        // against a stuck hasNextPage flag) and accumulate all children.
        const MAX_PAGES = 50;
        let page = 1;
        let allItems = [];
        let hasNextPage = true;
        while (hasNextPage && page <= MAX_PAGES) {
            // Only send pageId when scoping to a page's children; omit it for roots.
            const payload = { spaceId, page };
            if (pageId)
                payload.pageId = pageId;
            const response = await this.client.post("/pages/sidebar-pages", payload);
            const data = response.data?.data ?? response.data;
            const items = data?.items || [];
            allItems = allItems.concat(items);
            hasNextPage = data?.meta?.hasNextPage || false;
            page++;
        }
        return allItems;
    }
    /**
     * Enumerate EVERY page in a space (or in a subtree, when rootPageId is given)
     * by walking the sidebar-pages tree.
     *
     * Starting set: the children of rootPageId when provided, otherwise the
     * space root pages. From there it does an iterative breadth-first walk: each
     * node is collected, and when node.hasChildren is true its direct children
     * are fetched via listSidebarPages(spaceId, node.id) and enqueued.
     *
     * This replaces the old "/pages/recent" enumeration, which is a bounded
     * recent-activity feed (~5000 cap) and therefore misses comments on older
     * pages that were never recently touched.
     *
     * Safeguards: a `visited` Set of page ids prevents re-processing a node
     * (cycles / duplicate references), and a hard node cap bounds pathological
     * trees so the walk always terminates.
     */
    async enumerateSpacePages(spaceId, rootPageId) {
        const MAX_NODES = 10000;
        const result = [];
        const visited = new Set();
        // Seed the queue with the starting level (subtree children or roots).
        const queue = await this.listSidebarPages(spaceId, rootPageId);
        while (queue.length > 0 && result.length < MAX_NODES) {
            const node = queue.shift();
            if (!node || typeof node !== "object" || !node.id)
                continue;
            // Skip already-seen ids to guard against cycles / duplicate references.
            if (visited.has(node.id))
                continue;
            visited.add(node.id);
            result.push(node);
            if (node.hasChildren) {
                try {
                    const children = await this.listSidebarPages(spaceId, node.id);
                    for (const child of children)
                        queue.push(child);
                }
                catch (e) {
                    // A failure fetching one node's children must not abort the whole
                    // walk: skip this branch and keep enumerating the rest.
                }
            }
        }
        return result;
    }
    /** Raw page info including the ProseMirror JSON content and slugId. */
    async getPageRaw(pageId) {
        await this.ensureAuthenticated();
        const response = await this.client.post("/pages/info", { pageId });
        return response.data?.data ?? response.data;
    }
    async getPage(pageId) {
        await this.ensureAuthenticated();
        const resultData = await this.getPageRaw(pageId);
        let content = resultData.content
            ? convertProseMirrorToMarkdown(resultData.content)
            : "";
        // Always fetch subpages to provide context to the agent
        let subpages = [];
        try {
            // `pageId` may be a slugId, but the sidebar-pages endpoint requires the
            // UUID; `resultData.id` holds the resolved UUID returned by getPageRaw.
            subpages = await this.listSidebarPages(resultData.spaceId, resultData.id);
        }
        catch (e) {
            console.warn("Failed to fetch subpages:", e);
        }
        // Resolve subpages if the placeholder exists
        if (content && content.includes("{{SUBPAGES}}")) {
            if (subpages && subpages.length > 0) {
                const list = subpages
                    .map((p) => `- [${p.title}](page:${p.id})`)
                    .join("\n");
                content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
            }
            else {
                content = content.replace("{{SUBPAGES}}", "");
            }
        }
        return {
            data: filterPage(resultData, content, subpages),
            success: true,
        };
    }
    /** Page info + raw ProseMirror JSON content (lossless representation). */
    async getPageJson(pageId) {
        const data = await this.getPageRaw(pageId);
        return {
            id: data.id,
            slugId: data.slugId,
            title: data.title,
            parentPageId: data.parentPageId,
            spaceId: data.spaceId,
            updatedAt: data.updatedAt,
            content: data.content || { type: "doc", content: [] },
        };
    }
    /**
     * Compact outline of a page's top-level blocks (no full document body).
     * Cheap way to locate sections/tables and grab block ids before drilling in
     * with get_node / patch_node / insert_node.
     */
    async getOutline(pageId) {
        await this.ensureAuthenticated();
        const data = await this.getPageRaw(pageId);
        return {
            pageId,
            slugId: data.slugId,
            title: data.title,
            outline: buildOutline(data.content ?? { type: "doc", content: [] }),
        };
    }
    /**
     * Fetch a single node's full ProseMirror subtree (lossless) by reference:
     * a block id (headings/paragraphs/callouts/images), or `#<index>` to select
     * a top-level block by its outline index (the only way to reach tables/rows/
     * cells, which carry no id).
     */
    async getNode(pageId, nodeId) {
        await this.ensureAuthenticated();
        const data = await this.getPageRaw(pageId);
        const hit = getNodeByRef(data.content ?? { type: "doc", content: [] }, nodeId);
        if (!hit) {
            throw new Error(`get_node: no node found for "${nodeId}" on page ${pageId} (use a block id from get_outline, or "#<index>" for a top-level block such as a table)`);
        }
        return {
            pageId,
            ref: nodeId,
            path: hit.path,
            type: hit.type,
            node: hit.node,
        };
    }
    /**
     * Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
     * block id of any node inside the table. Returns the cell texts plus a
     * parallel cellIds matrix (each cell's first paragraph id, or null) so a
     * caller can patch_node a cell for rich-formatted edits. Throws when no table
     * resolves for the reference.
     */
    async getTable(pageId, tableRef) {
        await this.ensureAuthenticated();
        const data = await this.getPageRaw(pageId);
        const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef);
        if (!t) {
            throw new Error(`table_get: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from get_outline, or a block id inside the table)`);
        }
        return {
            pageId,
            table: tableRef,
            rows: t.rows,
            cols: t.cols,
            path: t.path,
            cells: t.cells,
            cellIds: t.cellIds,
        };
    }
    /**
     * Insert a row of plain-text cells into a table on the LIVE collab document.
     * `tableRef` is `#<index>` or a block id inside the target table. `cells` is
     * padded to the table's column count (more cells than columns throws); `index`
     * is a 0-based insert position (omit/out-of-range to append). Throws when no
     * table resolves for the reference.
     */
    async tableInsertRow(pageId, tableRef, cells, index) {
        await this.ensureAuthenticated();
        const collabToken = await this.getCollabTokenWithReauth();
        // Track insertion in an outer var, reset per-transform, so a collab retry
        // recomputes it cleanly (mirrors insertNode's pattern).
        let inserted = false;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            inserted = false;
            const { doc: nd, inserted: ins } = insertTableRow(liveDoc, tableRef, cells, index);
            inserted = ins;
            if (!inserted)
                return null; // table not found -> skip the write entirely
            return nd;
        });
        if (!inserted) {
            throw new Error(`table_insert_row: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from get_outline, or a block id inside the table)`);
        }
        return {
            success: true,
            table: tableRef,
            inserted: true,
            verify: mutation.verify,
        };
    }
    /**
     * Delete the row at 0-based `index` from a table on the LIVE collab document.
     * `tableRef` is `#<index>` or a block id inside the target table. The helper's
     * out-of-range and last-row errors propagate; a missing table throws here.
     */
    async tableDeleteRow(pageId, tableRef, index) {
        await this.ensureAuthenticated();
        const collabToken = await this.getCollabTokenWithReauth();
        let deleted = false;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            deleted = false;
            const { doc: nd, deleted: del } = deleteTableRow(liveDoc, tableRef, index);
            deleted = del;
            if (!deleted)
                return null; // table not found -> skip the write entirely
            return nd;
        });
        if (!deleted) {
            throw new Error(`table_delete_row: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from get_outline, or a block id inside the table)`);
        }
        return {
            success: true,
            table: tableRef,
            deleted: true,
            verify: mutation.verify,
        };
    }
    /**
     * Set the plain-text content of cell `[row, col]` (0-based) in a table on the
     * LIVE collab document, replacing the cell's content with a single text
     * paragraph (the cell's first-paragraph id is preserved). `tableRef` is
     * `#<index>` or a block id inside the target table. The helper's out-of-range
     * error propagates; a missing table throws here.
     */
    async tableUpdateCell(pageId, tableRef, row, col, text) {
        await this.ensureAuthenticated();
        const collabToken = await this.getCollabTokenWithReauth();
        let updated = false;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            updated = false;
            const { doc: nd, updated: upd } = updateTableCell(liveDoc, tableRef, row, col, text);
            updated = upd;
            if (!updated)
                return null; // table not found -> skip the write entirely
            return nd;
        });
        if (!updated) {
            throw new Error(`table_update_cell: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from get_outline, or a block id inside the table)`);
        }
        return {
            success: true,
            table: tableRef,
            row,
            col,
            verify: mutation.verify,
        };
    }
    /**
     * Create a new page with title and content.
     * Uses the /pages/import workaround (the only endpoint accepting content),
     * then moves the page and restores the exact title: the import endpoint
     * derives the title from the FILENAME and replaces spaces with
     * underscores, so we explicitly re-set it via /pages/update afterwards.
     */
    async createPage(title, content, spaceId, parentPageId) {
        await this.ensureAuthenticated();
        if (parentPageId) {
            try {
                await this.getPage(parentPageId);
            }
            catch (e) {
                throw new Error(`Parent page with ID ${parentPageId} not found.`);
            }
        }
        // 1. Create content via Import (using multipart/form-data).
        // Build a FRESH FormData per send attempt: a FormData body is a single-use
        // stream consumed on the first send, so it cannot be replayed by
        // this.client's response interceptor (replay fails with 'socket hang up').
        // Multipart re-auth is therefore done here with bare axios and an explicit
        // one-shot 401/403 retry that rebuilds the body.
        const fileContent = Buffer.from(content, "utf-8");
        const buildForm = () => {
            const form = new FormData();
            form.append("spaceId", spaceId);
            form.append("file", fileContent, {
                filename: `${title || "import"}.md`,
                contentType: "text/markdown",
            });
            return form;
        };
        const importUrl = `${this.apiUrl}/pages/import`;
        let response;
        try {
            // Call buildForm() ONCE per attempt and reuse the instance for both
            // getHeaders() and the body so the Content-Type boundary matches the body.
            const form = buildForm();
            // Read the Authorization header from this.client's defaults (set by
            // login(), only ever deleted — never set to null) instead of building
            // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
            // mid-flight, which would otherwise produce a literal "Bearer null".
            // ensureAuthenticated() above guarantees login() ran, so the default
            // header exists here.
            response = await axios.post(importUrl, form, {
                headers: {
                    ...form.getHeaders(),
                    Authorization: this.client.defaults.headers.common["Authorization"],
                },
                timeout: 60000,
            });
        }
        catch (error) {
            // On an expired-token auth error, re-login and retry exactly once with a
            // freshly-rebuilt FormData (the previous one was already consumed).
            if (axios.isAxiosError(error) &&
                (error.response?.status === 401 || error.response?.status === 403)) {
                await this.login();
                const form2 = buildForm();
                response = await axios.post(importUrl, form2, {
                    headers: {
                        ...form2.getHeaders(),
                        Authorization: this.client.defaults.headers.common["Authorization"],
                    },
                    timeout: 60000,
                });
            }
            else {
                throw error;
            }
        }
        const newPageId = (response.data?.data ?? response.data).id;
        // 2. Move to parent if needed
        if (parentPageId) {
            await this.movePage(newPageId, parentPageId);
        }
        // 3. Restore the exact title (import mangles spaces into underscores)
        if (title) {
            await this.client.post("/pages/update", { pageId: newPageId, title });
        }
        const page = await this.getPage(newPageId);
        // Surface non-fatal footnote problems (dangling refs, empty/duplicate
        // definitions, markers in tables) so the agent can fix its markup (#166).
        return { ...page, ...footnoteWarningsField(content) };
    }
    /**
     * Update a page's content from markdown and optionally its title.
     * NOTE: full re-import — block ids regenerate. For surgical changes
     * use editPageText / updatePageJson instead.
     */
    async updatePage(pageId, content, title) {
        await this.ensureAuthenticated();
        // Write the BODY first, then the title (#159 split-brain). If the collab
        // body write fails (e.g. a persist timeout), the title must be left
        // UNTOUCHED so the page never ends up with a new title over its old body.
        // A title write failing AFTER a successful body is rarer (REST is fast) and
        // leaves correct content under a stale title — the lesser inconsistency.
        let collabToken = "";
        let mutation;
        try {
            collabToken = await this.getCollabTokenWithReauth();
            mutation = await updatePageContentRealtime(pageId, content, collabToken, this.apiUrl);
        }
        catch (error) {
            // Verbose diagnostics (incl. anything that could expose a token prefix)
            // are gated behind DEBUG; the thrown Error below carries no token data.
            if (process.env.DEBUG) {
                console.error("Failed to update page content via realtime collaboration:", error);
                const tokenPreview = collabToken
                    ? collabToken.substring(0, 15) + "..."
                    : "null";
                console.error(`Collab token preview: ${tokenPreview}`);
            }
            throw new Error(`Failed to update page content: ${error.message}`);
        }
        // Body persisted successfully — now it is safe to set the title.
        if (title) {
            await this.client.post("/pages/update", { pageId, title });
        }
        return {
            success: true,
            modified: true,
            message: "Page updated successfully.",
            pageId: pageId,
            verify: mutation.verify,
            // Non-fatal footnote diagnostics (#166); omitted when there are none.
            ...footnoteWarningsField(content),
        };
    }
    /**
     * Validate a URL string against a scheme allowlist for a given context.
     *
     * The markdown link path enforces safe schemes via TipTap, but the raw
     * JSON path (updatePageJson) bypasses that — so this is the sanitization
     * choke point for ProseMirror JSON written directly by the caller.
     *
     * - "link":  reject javascript:, vbscript:, data: (any scheme that can
     *            execute or smuggle script when the href is clicked).
     * - "src":   allow only http(s):, mailto:, /api/files paths, or a
     *            scheme-less relative/absolute path; reject
     *            javascript:/vbscript:/data:/file:.
     */
    isSafeUrl(url, context) {
        if (typeof url !== "string")
            return false;
        const trimmed = url.trim();
        if (trimmed === "")
            return true; // empty href/src is harmless
        // Extract a leading "scheme:" if present. A scheme must start with a
        // letter and contain only letters/digits/+/-/. before the colon. Strip
        // whitespace and ASCII control chars first so a tab/newline embedded in
        // the scheme cannot smuggle a dangerous scheme past the check.
        const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, "");
        const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
        const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null;
        const dangerous = new Set(["javascript", "vbscript", "data", "file"]);
        if (context === "link") {
            if (scheme === null)
                return true; // relative/anchor link is fine
            // For links, data: is also blocked (can carry script payloads).
            return !new Set(["javascript", "vbscript", "data"]).has(scheme);
        }
        // context === "src"
        if (scheme === null)
            return true; // relative/absolute path (incl. /api/files)
        if (dangerous.has(scheme))
            return false;
        return scheme === "http" || scheme === "https" || scheme === "mailto";
    }
    /**
     * Recursively walk a ProseMirror doc and reject any unsafe URL on a link
     * mark href or on a media node's src/url. Media nodes covered: image,
     * attachment, video, plus embed (rendered as an iframe), youtube, drawio
     * and excalidraw — all of which carry a user-controlled URL that Docmost
     * renders. Throws a clear error on the first violation. A max-depth guard
     * turns an over-deep document into a clean error instead of a RangeError
     * stack overflow.
     */
    validateDocUrls(node, depth = 0) {
        const MAX_DEPTH = 200;
        if (depth > MAX_DEPTH) {
            throw new Error(`document nesting exceeds the maximum depth of ${MAX_DEPTH}`);
        }
        if (!node || typeof node !== "object")
            return;
        // Link marks on text nodes: validate the href.
        if (Array.isArray(node.marks)) {
            for (const mark of node.marks) {
                if (mark && mark.type === "link" && mark.attrs) {
                    if (!this.isSafeUrl(mark.attrs.href, "link")) {
                        throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`);
                    }
                }
            }
        }
        // Media nodes: validate src/url against the stricter src allowlist.
        // embed renders as an iframe (highest risk); youtube/drawio/excalidraw
        // likewise carry a user-controlled URL Docmost renders, so they get the
        // same scheme check as image/attachment/video.
        if (node.type === "image" ||
            node.type === "attachment" ||
            node.type === "video" ||
            node.type === "embed" ||
            node.type === "youtube" ||
            node.type === "drawio" ||
            node.type === "excalidraw" ||
            node.type === "audio" ||
            node.type === "pdf") {
            const attrs = node.attrs || {};
            for (const key of ["src", "url"]) {
                if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) {
                    throw new Error(`unsafe ${node.type} ${key} rejected: "${attrs[key]}"`);
                }
            }
        }
        if (Array.isArray(node.content)) {
            for (const child of node.content) {
                this.validateDocUrls(child, depth + 1);
            }
        }
    }
    /**
     * Recursively validate the STRUCTURE of a ProseMirror node (reuses the
     * recursion shape of validateDocUrls). Every node must be an object with a
     * string `type`; when present, `content` must be an array, `marks` must be
     * an array of objects each with a string `type`, and a text node's `text`
     * must be a string. Throws a clear "invalid ProseMirror document" error on
     * the first violation. A max-depth guard turns an over-deep document into a
     * clean error instead of a RangeError stack overflow.
     */
    validateDocStructure(node, depth = 0) {
        const MAX_DEPTH = 200;
        if (depth > MAX_DEPTH) {
            throw new Error(`invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`);
        }
        if (!node || typeof node !== "object" || typeof node.type !== "string") {
            throw new Error("invalid ProseMirror document: every node must be an object with a string `type`");
        }
        if ("text" in node &&
            node.type === "text" &&
            typeof node.text !== "string") {
            throw new Error("invalid ProseMirror document: a text node must have a string `text`");
        }
        if (node.marks !== undefined) {
            if (!Array.isArray(node.marks)) {
                throw new Error("invalid ProseMirror document: `marks` must be an array");
            }
            for (const mark of node.marks) {
                if (!mark ||
                    typeof mark !== "object" ||
                    typeof mark.type !== "string") {
                    throw new Error("invalid ProseMirror document: every mark must be an object with a string `type`");
                }
            }
        }
        if (node.content !== undefined) {
            if (!Array.isArray(node.content)) {
                throw new Error("invalid ProseMirror document: `content` must be an array when present");
            }
            for (const child of node.content) {
                this.validateDocStructure(child, depth + 1);
            }
        }
    }
    /**
     * Replace page content with a raw ProseMirror JSON document (lossless) and/or
     * update its title. Both `doc` and `title` are optional, but at least one must
     * be supplied:
     *  - `doc` provided   -> validate + full-overwrite the body (and update the
     *                        title too when `title` is also given).
     *  - `doc` omitted, `title` given -> title-only update; the body is NOT
     *                        touched/resent (no collab write happens).
     *  - neither given    -> throws (nothing to update).
     */
    async updatePageJson(pageId, doc, title) {
        await this.ensureAuthenticated();
        // Title-only / no-op handling: when no document is supplied, do NOT write
        // the body. Update the title if one was given; otherwise there is nothing
        // to do, so fail loudly rather than silently no-op.
        if (doc == null) {
            if (!title) {
                throw new Error("update_page_json: nothing to update (provide content and/or title)");
            }
            await this.client.post("/pages/update", { pageId, title });
            return {
                success: true,
                modified: true,
                message: "Page title updated (content left unchanged).",
                pageId,
            };
        }
        // Validate the document shape before a full overwrite: a malformed doc
        // would otherwise silently corrupt the page (full-overwrite is the
        // documented behaviour; no optimistic-concurrency is applied here).
        if (typeof doc !== "object" ||
            doc.type !== "doc" ||
            !Array.isArray(doc.content)) {
            throw new Error('content must be a ProseMirror document ({"type":"doc","content":[...]}) ' +
                "where content is an array of nodes each having a string `type`");
        }
        // Recurse the WHOLE document so a malformed nested node (e.g. a node with a
        // non-string type, a non-array content/marks, or a text node missing its
        // string text) is rejected up front rather than silently corrupting the
        // page on overwrite.
        this.validateDocStructure(doc);
        // Sanitize URLs before writing. This closes the JSON-path bypass: unlike
        // the markdown link path (which TipTap sanitizes), raw JSON could otherwise
        // inject javascript:/data: link hrefs or media srcs straight into the doc.
        this.validateDocUrls(doc);
        // Write the BODY first, then the title (#159 split-brain): a failed body
        // write (e.g. persist timeout) must not leave a new title over the old body.
        const collabToken = await this.getCollabTokenWithReauth();
        const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl);
        // Body persisted successfully — now it is safe to set the title.
        if (title) {
            await this.client.post("/pages/update", { pageId, title });
        }
        return {
            success: true,
            modified: true,
            message: "Page content replaced from ProseMirror JSON.",
            pageId,
            verify: mutation.verify,
        };
    }
    /**
     * Export a page to a single self-contained Docmost-flavoured markdown file:
     * meta block + body (with inline comment anchors + diagrams) + comment
     * threads. Lossless round-trip target; see importPageMarkdown for the inverse.
     */
    async exportPageMarkdown(pageId) {
        await this.ensureAuthenticated();
        const page = await this.getPageRaw(pageId);
        const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
        let comments = [];
        try {
            comments = await this.listComments(pageId);
        }
        catch (e) {
            // A comments fetch failure must not lose the body; export with [] and let
            // the caller see the (empty) comments block. Log under DEBUG only.
            if (process.env.DEBUG)
                console.error("export: listComments failed", e);
        }
        const meta = {
            version: 1,
            pageId: page.id,
            slugId: page.slugId,
            title: page.title,
            spaceId: page.spaceId,
            parentPageId: page.parentPageId ?? null,
        };
        return serializeDocmostMarkdown(meta, body, comments);
    }
    /**
     * Import a self-contained Docmost markdown file back into a page. Parses out
     * the meta + comments metadata blocks, converts the body to ProseMirror
     * (restoring comment marks + diagrams from their inline HTML), and replaces
     * the page content. Comment THREAD records are NOT written to the server in
     * this version — they are preserved in the file and the inline marks are
     * re-applied so the highlights survive; managing comment records stays with
     * the comment tools/UI.
     */
    async importPageMarkdown(pageId, fullMarkdown) {
        await this.ensureAuthenticated();
        const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown);
        const doc = await markdownToProseMirror(body);
        const collabToken = await this.getCollabTokenWithReauth();
        const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl);
        // Collect distinct comment ids that actually became comment marks in the doc.
        const collectCommentIds = (node, acc) => {
            if (!node || typeof node !== "object")
                return acc;
            if (Array.isArray(node.marks)) {
                for (const mk of node.marks) {
                    if (mk && mk.type === "comment" && mk.attrs?.commentId) {
                        acc.add(mk.attrs.commentId);
                    }
                }
            }
            if (Array.isArray(node.content)) {
                for (const child of node.content)
                    collectCommentIds(child, acc);
            }
            return acc;
        };
        // Count reflects the comment marks present in the written document, so an id
        // that only appears as inert text (e.g. inside a fenced code block) is not
        // counted because it never becomes a comment mark.
        const anchoredIds = collectCommentIds(doc, new Set());
        const result = {
            success: true,
            pageId,
            anchoredCommentCount: anchoredIds.size,
            commentsInFile: Array.isArray(comments) ? comments.length : 0,
            verify: mutation.verify,
        };
        // Warn (non-fatal) if the file was exported from a DIFFERENT page.
        if (meta?.pageId && meta.pageId !== pageId) {
            result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`;
        }
        // Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after
        // the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside
        // those JSON blocks never produces a false warning, while real markers in the
        // body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above.
        Object.assign(result, footnoteWarningsField(body));
        return result;
    }
    /**
     * Rename a page (change its title only) without touching or resending its
     * content. The slug is derived from the page record, not the body, so it is
     * left intact too.
     */
    async renamePage(pageId, title) {
        await this.ensureAuthenticated();
        await this.client.post("/pages/update", { pageId, title });
        return { success: true, pageId, title };
    }
    /**
     * Copy the WHOLE content of one page onto another, entirely server-side: the
     * source's ProseMirror document is read and written verbatim onto the target
     * via the live collab path, so the document never passes through the model.
     *
     * Only the target's BODY is replaced — its title and slug live on the page
     * record (not in the content), so they are untouched. The source page is not
     * modified at all.
     */
    async copyPageContent(sourcePageId, targetPageId) {
        await this.ensureAuthenticated();
        // A self-copy would be a no-op overwrite; reject it explicitly so a caller
        // mistake surfaces as a clear error rather than a silent round-trip.
        if (sourcePageId === targetPageId) {
            throw new Error("copy_page_content: sourcePageId and targetPageId are the same page (no-op copy)");
        }
        const source = await this.getPageRaw(sourcePageId);
        const content = source?.content;
        if (!content ||
            typeof content !== "object" ||
            content.type !== "doc" ||
            !Array.isArray(content.content)) {
            throw new Error(`copy_page_content: source page ${sourcePageId} has no usable ProseMirror content to copy`);
        }
        // Defense-in-depth: run the same URL-scheme sanitizer the JSON write path
        // uses, so copying never lands a javascript:/data: href/src on the target
        // (parity with updatePageJson; harmless for already-stored source content).
        this.validateDocUrls(content);
        const collabToken = await this.getCollabTokenWithReauth();
        const mutation = await replacePageContent(targetPageId, content, collabToken, this.apiUrl);
        return {
            success: true,
            sourcePageId,
            targetPageId,
            copiedNodes: content.content.length,
            verify: mutation.verify,
        };
    }
    /**
     * Surgical text edits: find/replace inside text nodes of the live
     * document. Preserves all block ids, marks, callouts and tables.
     */
    async editPageText(pageId, edits) {
        await this.ensureAuthenticated();
        const collabToken = await this.getCollabTokenWithReauth();
        // Apply the edits against the LIVE synced document, not the debounced REST
        // snapshot, so concurrent human edits/comments are preserved. applyTextEdits
        // records per-edit match problems in `failed` instead of throwing, and
        // applies whatever it can; we abort the write only when nothing applied.
        let results;
        let failed;
        // Whether we actually wrote new content. Set inside the transform: a
        // degenerate edit (e.g. find === replace, or a batch that nets to no change)
        // can "apply" yet leave the document byte-for-byte identical, in which case
        // we must NOT write (no spurious history version) and must not claim a write
        // happened.
        let wrote = false;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            wrote = false;
            const r = applyTextEdits(liveDoc, edits);
            results = r.results;
            failed = r.failed;
            // Nothing applied -> abort the write (mutatePageContent treats a null
            // return from the transform as "write nothing").
            if (r.results.length === 0)
                return null;
            // Edits "applied" but produced an identical document: skip the write so
            // no new history version is created. Stable structural comparison via
            // JSON.stringify (both docs come from the same deep-copied source, so
            // key order is stable).
            if (JSON.stringify(r.doc) === JSON.stringify(liveDoc))
                return null;
            wrote = true;
            return r.doc;
        });
        if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) {
            // No edit applied: surface an aggregated, actionable error so the caller
            // does not mistake a no-op for a partial success.
            throw new Error("edit_page_text: no edits were applied (nothing written). " +
                failed.map((f) => `"${f.find}": ${f.reason}`).join("; "));
        }
        // Edits matched but produced no content change (identical document): report
        // a successful no-op — NOT a failure — and do not falsely claim a write.
        if (!wrote) {
            return {
                success: true,
                pageId,
                applied: results,
                failed,
                message: "No changes written (edits produced identical content).",
                verify: mutation.verify,
            };
        }
        const result = {
            success: true,
            pageId,
            applied: results,
            failed,
            message: (failed?.length ?? 0)
                ? `Applied ${results?.length ?? 0} edit(s); ${failed.length} failed (see failed[]). Node ids and formatting preserved.`
                : "Text edits applied (node ids and formatting preserved).",
            verify: mutation.verify,
        };
        // If any applied edit matched only after stripping markdown (the
        // normalized fallback), warn that edit_page_text preserved existing marks
        // and did NOT change formatting — so a caller who intended a formatting
        // change is pointed at patch_node.
        if (results?.some((r) => r.normalized === true)) {
            result.warning =
                "Some edits matched only after stripping markdown from your find string; " +
                    "edit_page_text preserved existing marks (it did not change bold/strike/etc.). " +
                    "If you intended a formatting change, use patch_node.";
        }
        return result;
    }
    /**
     * Replace EVERY node whose attrs.id === nodeId (recursively, including nodes
     * nested in callouts/tables) with the supplied node. Operates on the LIVE
     * collab document so comments and concurrent edits are preserved.
     *
     * The replacement node's block id is preserved: if node.attrs is missing it
     * is created, and if node.attrs.id is missing it is set to nodeId so the
     * replacement keeps the same id it replaced. Throws if no node matches.
     */
    async patchNode(pageId, nodeId, node) {
        await this.ensureAuthenticated();
        if (!node || typeof node !== "object" || typeof node.type !== "string") {
            throw new Error("patch_node: `node` must be an object with a string `type`");
        }
        // Preserve the block id WITHOUT mutating the caller's object: build a local
        // copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the
        // node it replaces).
        const target = {
            ...node,
            attrs: {
                ...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}),
            },
        };
        if (target.attrs.id == null) {
            target.attrs.id = nodeId;
        }
        const collabToken = await this.getCollabTokenWithReauth();
        // Track the replacement count in an outer var, reset per-transform, so a
        // collab retry recomputes it cleanly (mirrors replaceImage's pattern).
        let replaced = 0;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            replaced = 0;
            const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
            replaced = r;
            // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
            // duplicates block ids on copy/paste (and copyPageContent writes them
            // verbatim), so replacing "the node with id X" would silently clobber
            // EVERY duplicate (#159). Refuse: skip the write and throw below so the
            // model re-targets with a more specific anchor instead of corrupting the
            // page. Only an unambiguous single match is written.
            if (replaced !== 1)
                return null;
            return nd;
        });
        // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
        // the write for any count !== 1). Single shared guard (#159, #185 review).
        assertUnambiguousMatch("patch_node", "replace", replaced, nodeId, pageId);
        return { success: true, replaced, nodeId, verify: mutation.verify };
    }
    /**
     * Insert a node relative to an anchor (or append it at the top level).
     * Operates on the LIVE collab document so comments and concurrent edits are
     * preserved.
     *
     * opts.position:
     *  - "append": push the node at the end of the top-level content.
     *  - "before"/"after": insert the node as a sibling of the anchor, just
     *    before/after it. Exactly one of anchorNodeId / anchorText must be given;
     *    anchorNodeId locates a node anywhere by attrs.id, anchorText matches the
     *    first top-level block whose plain text includes it.
     *
     * Throws if the anchor cannot be found.
     */
    async insertNode(pageId, node, opts) {
        await this.ensureAuthenticated();
        if (!node || typeof node !== "object" || typeof node.type !== "string") {
            throw new Error("insert_node: `node` must be an object with a string `type`");
        }
        if (!opts ||
            (opts.position !== "before" &&
                opts.position !== "after" &&
                opts.position !== "append")) {
            throw new Error('insert_node: `position` must be one of "before", "after", "append"');
        }
        if (opts.position === "before" || opts.position === "after") {
            // before/after require EXACTLY ONE anchor (an id or a text fragment).
            const hasId = typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0;
            const hasText = typeof opts.anchorText === "string" && opts.anchorText.length > 0;
            if (hasId === hasText) {
                throw new Error(`insert_node: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`);
            }
        }
        const collabToken = await this.getCollabTokenWithReauth();
        // Track insertion in an outer var, reset per-transform, so a collab retry
        // recomputes it cleanly (mirrors replaceImage's pattern).
        let inserted = false;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            inserted = false;
            const { doc: nd, inserted: ins } = insertNodeRelative(liveDoc, node, opts);
            inserted = ins;
            if (!inserted)
                return null; // anchor not found -> skip the write entirely
            return nd;
        });
        if (!inserted) {
            const anchorDesc = opts.anchorNodeId
                ? `anchorNodeId "${opts.anchorNodeId}"`
                : `anchorText "${opts.anchorText}"`;
            // anchorText is matched against the block's literal RENDERED plain text;
            // markdown/emoji are tolerated only as a strip-and-retry fallback, so a
            // miss usually means the text differs from what's on the page.
            const hint = opts.anchorText
                ? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from get_page_json is more reliable."
                : "";
            throw new Error(`insert_node: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`);
        }
        return {
            success: true,
            inserted: true,
            position: opts.position,
            verify: mutation.verify,
        };
    }
    /**
     * Remove EVERY node whose attrs.id === nodeId (recursively, including nodes
     * nested in callouts/tables) from its parent content array. Operates on the
     * LIVE collab document so comments and concurrent edits are preserved.
     * Throws if no node matches.
     */
    async deleteNode(pageId, nodeId) {
        await this.ensureAuthenticated();
        const collabToken = await this.getCollabTokenWithReauth();
        // Track the deletion count in an outer var, reset per-transform, so a
        // collab retry recomputes it cleanly (mirrors replaceImage's pattern).
        let deleted = 0;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            deleted = 0;
            const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
            deleted = d;
            // 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
            // ids are duplicated on copy/paste, #159): deleting "the node with id X"
            // would silently remove EVERY duplicate. Refuse: skip the write and throw
            // below so the model re-targets. Only an unambiguous single match is
            // deleted.
            if (deleted !== 1)
                return null;
            return nd;
        });
        // 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
        // the write for any count !== 1). Single shared guard (#159, #185 review).
        assertUnambiguousMatch("delete_node", "delete", deleted, nodeId, pageId);
        return { success: true, deleted, nodeId, verify: mutation.verify };
    }
    /** Build the public share URL for a page. */
    shareUrl(shareKey, slugId) {
        return `${this.appUrl}/share/${shareKey}/p/${slugId}`;
    }
    /** Share a page publicly (idempotent) and return the public URL. */
    async sharePage(pageId, searchIndexing = true) {
        await this.ensureAuthenticated();
        const response = await this.client.post("/shares/create", {
            pageId,
            includeSubPages: false,
            searchIndexing,
        });
        const share = response.data?.data ?? response.data;
        const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId;
        return {
            shareId: share.id,
            key: share.key,
            pageId: share.pageId,
            publicUrl: this.shareUrl(share.key, slugId),
            searchIndexing: share.searchIndexing,
        };
    }
    /** List all public shares in the workspace with their URLs. */
    async listShares() {
        const shares = await this.paginateAll("/shares", {});
        return shares.map((s) => ({
            shareId: s.id,
            key: s.key,
            pageId: s.pageId,
            pageTitle: s.page?.title,
            publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null,
            searchIndexing: s.searchIndexing,
            createdAt: s.createdAt,
        }));
    }
    /** Remove the public share of a page. */
    async unsharePage(pageId) {
        await this.ensureAuthenticated();
        const shares = await this.listShares();
        const share = shares.find((s) => s.pageId === pageId);
        if (!share) {
            throw new Error(`Page ${pageId} is not shared.`);
        }
        await this.client.post("/shares/delete", { shareId: share.shareId });
        return { success: true, removedShareId: share.shareId, pageId };
    }
    async search(query, spaceId, limit) {
        await this.ensureAuthenticated();
        const payload = { query, spaceId };
        // Clamp an optional caller-supplied limit into a sane 1..100 range before
        // forwarding it to the server; omit it entirely when not provided so the
        // server applies its own default.
        if (limit !== undefined) {
            payload.limit = Math.max(1, Math.min(100, limit));
        }
        const response = await this.client.post("/search", payload);
        // Normalize both response shapes: bare array and paginated { items: [...] }
        const data = response.data?.data;
        const items = Array.isArray(data) ? data : data?.items || [];
        const filteredItems = items.map((item) => filterSearchResult(item));
        return {
            items: filteredItems,
            success: response.data?.success || false,
        };
    }
    async movePage(pageId, parentPageId, position) {
        await this.ensureAuthenticated();
        // Docmost requires position >= 5 chars.
        const validPosition = position || "a00000";
        return this.client
            .post("/pages/move", {
            pageId,
            parentPageId,
            position: validPosition,
        })
            .then((res) => res.data);
    }
    async deletePage(pageId) {
        await this.ensureAuthenticated();
        return this.client
            .post("/pages/delete", { pageId })
            .then((res) => res.data);
    }
    // --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
    /**
     * Normalize a comment's `content` into a ProseMirror doc object before
     * markdown conversion. createComment/updateComment send content as a
     * JSON.stringify(...) STRING, and the server stores it as-is, so on read it
     * comes back as a string. convertProseMirrorToMarkdown returns "" for a
     * string, so parse it first (guarded — fall back to the raw value on any
     * parse failure so a non-JSON legacy value is still handled gracefully).
     */
    parseCommentContent(content) {
        if (typeof content !== "string")
            return content;
        try {
            return JSON.parse(content);
        }
        catch {
            return content;
        }
    }
    /** List all comments on a page (cursor-paginated), content as markdown. */
    async listComments(pageId) {
        await this.ensureAuthenticated();
        let allComments = [];
        let cursor = null;
        do {
            const payload = { pageId, limit: 100 };
            if (cursor)
                payload.cursor = cursor;
            const response = await this.client.post("/comments", payload);
            const data = response.data.data || response.data;
            const items = data.items || [];
            allComments = allComments.concat(items);
            cursor = data.meta?.nextCursor || null;
        } while (cursor);
        return allComments.map((comment) => {
            const markdown = comment.content
                ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
                : "";
            return filterComment(comment, markdown);
        });
    }
    async getComment(commentId) {
        await this.ensureAuthenticated();
        const response = await this.client.post("/comments/info", { commentId });
        const comment = response.data.data || response.data;
        const markdown = comment.content
            ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
            : "";
        return {
            data: filterComment(comment, markdown),
            success: true,
        };
    }
    /**
     * Create an inline comment anchored to its `selection` text, or a reply.
     *
     * Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a
     * `selection`: the `type` argument is kept for interface compatibility but the
     * effective type is coerced to "inline". The selection has to anchor in the
     * document; if it cannot, the comment is rolled back and an error is thrown so
     * the caller is forced to supply a proper inline selection rather than leaving
     * an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
     * their parent's anchor: they take NO selection and are not anchored.
     */
    async createComment(pageId, content, type = "page", selection, parentCommentId) {
        await this.ensureAuthenticated();
        const isReply = !!parentCommentId;
        // Only top-level comments are inline-anchored, so they are stored as
        // "inline". Replies carry no inline selection, so they keep the historical
        // general ("page") type — both backward-compatible and semantically correct.
        // The `type` argument is kept for interface compatibility; createComment
        // normalizes the effective type internally, so callers may pass "inline".
        const effectiveType = isReply ? "page" : "inline";
        if (!isReply && (!selection || !selection.trim())) {
            throw new Error("create_comment: an inline 'selection' (exact text to anchor on) is required for a top-level comment");
        }
        // For a top-level comment, fail BEFORE creating anything when the selection
        // is not present in the persisted document — this avoids leaving an orphan
        // comment + notification behind. A read failure (network) is non-fatal: the
        // live anchor step below still enforces the anchoring invariant.
        if (!isReply && selection) {
            try {
                const page = await this.getPageJson(pageId);
                if (!canAnchorInDoc(page.content, selection)) {
                    throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " +
                        "Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).");
                }
            }
            catch (e) {
                // Rethrow our own "not found" error; swallow read/network errors so the
                // live anchor step can still try (and enforce) the anchoring.
                if (e instanceof Error &&
                    e.message.startsWith("create_comment: could not find the selection")) {
                    throw e;
                }
                if (process.env.DEBUG) {
                    console.error("Pre-check getPageJson failed; deferring to live anchor step:", e);
                }
            }
        }
        // Convert through the full Docmost schema (consistent with page paths)
        const jsonContent = await markdownToProseMirror(content);
        const payload = {
            pageId,
            content: JSON.stringify(jsonContent),
            type: effectiveType,
        };
        if (!isReply && selection)
            payload.selection = selection;
        if (parentCommentId)
            payload.parentCommentId = parentCommentId;
        const response = await this.client.post("/comments/create", payload);
        const comment = response.data.data || response.data;
        const markdown = comment.content
            ? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
            : content;
        const result = {
            data: filterComment(comment, markdown),
            success: true,
        };
        // Replies inherit the parent's anchor: no selection, no anchoring.
        if (isReply) {
            return result;
        }
        // Anchor the comment in the document. The /comments/create API records the
        // comment + its `selection` text, but it does NOT insert the comment MARK
        // into the page content, so without this the inline comment has no
        // highlight/anchor and is not clickable. If anchoring fails the comment is
        // rolled back (deleted) and an error is thrown — never an orphan comment.
        const newCommentId = comment.id;
        // Guard: a create response without an id would mean writing a comment mark
        // with commentId: undefined and a later delete of a falsy id. We have no id
        // to roll back here (nothing was created with an id), so just fail loudly.
        if (!newCommentId) {
            throw new Error("create_comment: the server returned no comment id, so the comment could not be anchored");
        }
        let anchored = false;
        try {
            const collabToken = await this.getCollabTokenWithReauth();
            const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
                const doc = liveDoc && liveDoc.type === "doc"
                    ? liveDoc
                    : { type: "doc", content: [] };
                if (applyAnchorInDoc(doc, selection, newCommentId)) {
                    anchored = true;
                    return doc;
                }
                // Selection text not found in the LIVE document: abort the write. The
                // rollback + throw below turns this into a hard error.
                return null;
            });
            result.verify = mutation.verify;
        }
        catch (e) {
            // The comment record already exists; roll it back so we never leave an
            // orphan, then rethrow the original anchoring error.
            await this.safeDeleteComment(newCommentId);
            throw e;
        }
        if (!anchored) {
            // Mutation aborted because the selection was not found in the live
            // document. Roll back the comment and surface a hard error.
            await this.safeDeleteComment(newCommentId);
            throw new Error("create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
        }
        result.anchored = true;
        return result;
    }
    /**
     * Best-effort rollback of a just-created comment. Swallows any delete failure
     * (logging under DEBUG) so a failed cleanup never masks the original error.
     */
    async safeDeleteComment(commentId) {
        // Defense in depth: never call the delete API with a falsy id — there is
        // nothing to roll back, and deleteComment(undefined) would hit a bad route.
        if (!commentId)
            return;
        try {
            await this.deleteComment(commentId);
        }
        catch (delErr) {
            if (process.env.DEBUG) {
                console.error("Failed to roll back comment after anchoring error:", delErr);
            }
        }
    }
    async updateComment(commentId, content) {
        await this.ensureAuthenticated();
        const jsonContent = await markdownToProseMirror(content);
        await this.client.post("/comments/update", {
            commentId,
            content: JSON.stringify(jsonContent),
        });
        return {
            success: true,
            commentId,
            message: "Comment updated successfully.",
        };
    }
    async deleteComment(commentId) {
        await this.ensureAuthenticated();
        return this.client
            .post("/comments/delete", { commentId })
            .then((res) => res.data);
    }
    /**
     * Resolve or reopen a top-level comment thread (reversible — `resolved`
     * toggles the state). Only top-level comments can be resolved; the server
     * rejects resolving a reply. Hits POST /comments/resolve.
     */
    async resolveComment(commentId, resolved) {
        await this.ensureAuthenticated();
        const response = await this.client.post("/comments/resolve", {
            commentId,
            resolved,
        });
        const comment = response.data?.data ?? response.data;
        return {
            success: true,
            commentId,
            resolved,
            comment,
        };
    }
    /**
     * Check for new comments across pages in a space (optionally scoped to a
     * subtree): pages updated after `since` are scanned and their comments
     * filtered by createdAt > since.
     */
    async checkNewComments(spaceId, since, parentPageId) {
        await this.ensureAuthenticated();
        const sinceDate = new Date(since);
        // Reject an unparseable `since`: comparing against an Invalid Date silently
        // yields zero new comments (every `>` against NaN is false), which would
        // mask a malformed input as "nothing new" instead of erroring.
        if (Number.isNaN(sinceDate.getTime())) {
            throw new Error(`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`);
        }
        // 1. Enumerate the FULL set of pages in scope by walking the sidebar-pages
        // tree (a complete page index), NOT the bounded "/pages/recent" feed which
        // caps at ~5000 recent items and silently misses comments on older pages.
        //
        // Subtree scope: when parentPageId is given, the scope is that page ITSELF
        // plus every descendant (enumerateSpacePages walks its children). Otherwise
        // the scope is the whole space (all roots and their descendants).
        //
        // NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
        // bump it (verified on a live server), so such a filter silently misses
        // comments on pages that were not otherwise edited. The complete tree walk
        // already restricts the scope correctly, so no recent-feed allow-list is
        // needed any more.
        let pagesInScope;
        if (parentPageId) {
            const subtree = await this.enumerateSpacePages(spaceId, parentPageId);
            // Include the parent page node itself alongside its descendants. Fetch it
            // so its title/id are available even though it is not returned by its own
            // children listing.
            let parentNode = { id: parentPageId };
            try {
                parentNode = await this.getPageRaw(parentPageId);
            }
            catch (e) {
                // Fall back to a minimal node if the parent can't be fetched; its
                // comments are still attempted below (the fetch there is non-fatal).
            }
            pagesInScope = [parentNode, ...subtree];
        }
        else {
            pagesInScope = await this.enumerateSpacePages(spaceId);
        }
        // 2. Fetch comments for each page, keep ones created after since
        const results = [];
        for (const page of pagesInScope) {
            try {
                const comments = await this.listComments(page.id);
                const newComments = comments.filter((c) => new Date(c.createdAt) > sinceDate);
                if (newComments.length > 0) {
                    results.push({
                        pageId: page.id,
                        pageTitle: page.title,
                        comments: newComments,
                    });
                }
            }
            catch (e) {
                // Skip pages with errors (e.g. deleted between calls)
            }
        }
        const totalNewComments = results.reduce((sum, r) => sum + r.comments.length, 0);
        // enumerateSpacePages caps traversal at 10000 nodes; flag when that cap was
        // hit so the caller knows the scan may be incomplete (some pages skipped).
        const truncated = pagesInScope.length >= 10000;
        return {
            since,
            scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
            checkedPages: pagesInScope.length,
            pagesWithNewComments: results.length,
            totalNewComments,
            truncated,
            comments: results,
        };
    }
    // --- Image upload / embedding ---
    /** Map a Content-Type string to a supported MIME type, or null if unsupported. */
    supportedImageMime(ct) {
        return MIME_TO_EXT[ct] ? ct : null;
    }
    /**
     * Download a remote image from a caller-supplied URL and resolve its bytes,
     * MIME and a filename.
     *
     * SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is
     * fetched BY THE SERVER, so it must be guarded before and after the request.
     * The guards mirror the local-file trust boundary in uploadImage:
     *   - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc.,
     *     so the caller cannot use this path to read local files or other schemes;
     *   - a size cap enforced both via axios maxContentLength/maxBodyLength AND a
     *     post-download buffer.length re-check (defends against a missing/lying
     *     Content-Length), so a huge response cannot exhaust memory;
     *   - a 30s timeout. The timeout matters because replaceImage holds the
     *     per-page lock across this upload, so a hung download would wedge the
     *     lock for that page.
     * We deliberately do NOT block private IP ranges: the MCP caller is already
     * trusted to read arbitrary host files via the filePath path, so the marginal
     * trust granted by fetching internal URLs is comparable, and blocking would
     * break legitimate internal-image use.
     */
    async fetchRemoteImage(url, maxBytes) {
        // Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes
        // (file:, data:, ftp:, ...) before any network request is made.
        let parsed;
        try {
            parsed = new URL(url);
        }
        catch (e) {
            throw new Error(`Invalid image URL "${url}": ${e.message}`);
        }
        if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
            throw new Error(`unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`);
        }
        let response;
        try {
            response = await axios.get(url, {
                responseType: "arraybuffer",
                timeout: 30000,
                maxContentLength: maxBytes,
                maxBodyLength: maxBytes,
                headers: { Accept: "image/*" },
            });
        }
        catch (error) {
            // Keep the thrown message free of the raw response body (it may echo
            // server internals); surface only status/statusText. The full body is
            // logged under DEBUG for diagnostics.
            if (axios.isAxiosError(error)) {
                if (process.env.DEBUG) {
                    console.error("Image download failed; response body:", JSON.stringify(error.response?.data));
                }
                throw new Error(`Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim());
            }
            throw error;
        }
        // axios returns an ArrayBuffer for responseType: "arraybuffer".
        const buffer = Buffer.from(response.data);
        // Re-check the size: maxContentLength relies on Content-Length, which may be
        // absent or lie, so guard against the actual byte count too.
        if (buffer.length === 0) {
            throw new Error(`Empty image response from "${url}"`);
        }
        if (buffer.length > maxBytes) {
            throw new Error(`Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`);
        }
        // Resolve MIME: prefer the response Content-Type (strip any "; charset=..."
        // parameter, lowercase, trim) mapped through the supported set; if the
        // header is generic/missing/unsupported, fall back to the URL path
        // extension via the existing extension->MIME logic.
        const rawCt = response.headers?.["content-type"];
        let mime = null;
        if (typeof rawCt === "string" && rawCt.length > 0) {
            const ct = rawCt.split(";")[0].trim().toLowerCase();
            mime = this.supportedImageMime(ct);
        }
        if (!mime) {
            // Fall back to the URL path extension. Use the pathname so the query
            // string never contaminates the extension lookup.
            const ext = extname(parsed.pathname).toLowerCase();
            mime = EXT_TO_MIME[ext] ?? null;
        }
        if (!mime) {
            throw new Error(`cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`);
        }
        // Build a filename from the URL path basename (ignore the query string),
        // defaulting to "image" when empty, and ensure it ends with the canonical
        // extension for the resolved MIME (append it when missing/mismatched).
        const canonicalExt = MIME_TO_EXT[mime];
        let fileName = basename(parsed.pathname) || "image";
        if (extname(fileName).toLowerCase() !== canonicalExt) {
            fileName += canonicalExt;
        }
        return { buffer, mime, fileName };
    }
    /** Build a Docmost ProseMirror image node from an uploaded attachment. */
    buildImageNode(att, align, alt) {
        // Clean file URL, matching Docmost's native behaviour. No cache-busting
        // query: the server serves the bare URL correctly, and replacement creates
        // a new attachment id (a new URL) which busts caches naturally.
        const src = `/api/files/${att.id}/${att.fileName}`;
        const node = {
            type: "image",
            attrs: {
                src,
                attachmentId: att.id,
                // Default to null when the server omits fileSize so the attr is never
                // undefined (undefined would be dropped on serialization / break the
                // ProseMirror image schema which expects size present).
                size: att.fileSize ?? null,
                align: align || "center",
                width: null,
            },
        };
        if (alt)
            node.attrs.alt = alt;
        return node;
    }
    /**
     * Download a remote image from an http(s) URL and upload it as an attachment
     * of a page, returning the attachment metadata plus a ready-to-insert
     * ProseMirror image node. Local file paths are intentionally not supported:
     * the MCP caller is a remote AI with no access to this server's filesystem.
     */
    async uploadImage(pageId, url) {
        await this.ensureAuthenticated();
        const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB
        // Fetch + validate the remote image (scheme allowlist, size cap, timeout).
        // See fetchRemoteImage for the SSRF / resource trust boundary.
        const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES);
        const fileBuffer = fetched.buffer;
        const mime = fetched.mime;
        const fileName = fetched.fileName;
        // Build a FRESH FormData for every send attempt. A FormData body is a
        // single-use stream that is CONSUMED on the first send, so it cannot be
        // replayed by this.client's response interceptor (replaying a consumed
        // stream fails with 'socket hang up'). Multipart re-auth is therefore done
        // here with bare axios and an explicit one-shot 401/403 retry that rebuilds
        // the body. Field order matters: text fields must precede the file part so
        // the server reads them; the server always generates a fresh attachment id.
        const buildForm = () => {
            const form = new FormData();
            form.append("pageId", pageId);
            form.append("file", fileBuffer, {
                filename: fileName,
                contentType: mime,
            });
            return form;
        };
        // Local name distinct from the `url` parameter (the source image URL): this
        // is the /files/upload endpoint we POST the multipart body to.
        const uploadUrl = `${this.apiUrl}/files/upload`;
        let response;
        try {
            // Call buildForm() ONCE per attempt and reuse the instance for both
            // getHeaders() and the body so the Content-Type boundary matches the body.
            const form = buildForm();
            // Read the Authorization header from this.client's defaults (set by
            // login(), only ever deleted — never set to null) instead of building
            // `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
            // mid-flight, which would otherwise produce a literal "Bearer null".
            // ensureAuthenticated() above guarantees login() ran, so the default
            // header exists here. A 60s timeout keeps a hung upload from wedging the
            // per-page lock (replaceImage holds withPageLock across this call).
            response = await axios.post(uploadUrl, form, {
                headers: {
                    ...form.getHeaders(),
                    Authorization: this.client.defaults.headers.common["Authorization"],
                },
                timeout: 60000,
            });
        }
        catch (error) {
            // On an expired-token auth error, re-login and retry exactly once with a
            // freshly-rebuilt FormData (the previous one was already consumed).
            if (axios.isAxiosError(error) &&
                (error.response?.status === 401 || error.response?.status === 403)) {
                await this.login();
                const form2 = buildForm();
                response = await axios.post(uploadUrl, form2, {
                    headers: {
                        ...form2.getHeaders(),
                        Authorization: this.client.defaults.headers.common["Authorization"],
                    },
                    timeout: 60000,
                });
            }
            else if (axios.isAxiosError(error)) {
                // Keep the thrown message free of the raw response body (it may echo
                // request data or server internals); surface only status/statusText.
                // The full body is logged under DEBUG for diagnostics.
                if (process.env.DEBUG) {
                    console.error("Image upload failed; response body:", JSON.stringify(error.response?.data));
                }
                throw new Error(`Image upload failed: ${error.response?.status} ${error.response?.statusText}`);
            }
            else {
                throw error;
            }
        }
        // The attachment may arrive bare or wrapped in a { data } envelope.
        const att = response.data?.data ?? response.data;
        if (!att?.id || !att?.fileName) {
            throw new Error("Unexpected /files/upload response: " + JSON.stringify(response.data));
        }
        // Some Docmost versions omit fileSize from the upload response. Fall back
        // to the fetched byte length (the bytes we just uploaded) so callers never
        // get an undefined size.
        const resolvedSize = att.fileSize ?? fileBuffer.length;
        return {
            attachmentId: att.id,
            fileName: att.fileName,
            fileSize: resolvedSize,
            src: `/api/files/${att.id}/${att.fileName}`,
            imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }),
        };
    }
    /**
     * Upload an image from a web (http/https) URL and insert it into a page in
     * one step.
     * By default the image is appended at the end. With replaceText, the first
     * top-level block whose text contains the string is replaced; with afterText,
     * the image is inserted right after the first matching block. All other
     * block ids are preserved (only one top-level block is added or swapped).
     */
    async insertImage(pageId, url, opts = {}) {
        const up = await this.uploadImage(pageId, url);
        // Reuse the node from uploadImage (clean /api/files/<id>/<file> src), then
        // apply align/alt onto a shallow attrs copy.
        const node = { ...up.imageNode, attrs: { ...up.imageNode.attrs } };
        if (opts.align)
            node.attrs.align = opts.align;
        if (opts.alt)
            node.attrs.alt = opts.alt;
        const collabToken = await this.getCollabTokenWithReauth();
        // Recursively collect the plain text of a top-level block.
        const blockText = (n) => {
            let out = "";
            if (n.type === "text")
                out += n.text || "";
            for (const child of n.content || [])
                out += blockText(child);
            return out;
        };
        // Insert into the LIVE synced document, not the debounced REST snapshot, so
        // concurrent edits/comments/images are preserved and parallel insert_image
        // calls (serialized by the per-page lock) each see the previous insertion.
        let placement;
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
            const doc = liveDoc && liveDoc.type === "doc"
                ? liveDoc
                : { type: "doc", content: [] };
            if (!Array.isArray(doc.content))
                doc.content = [];
            if (opts.replaceText) {
                // Ambiguity guard (mirrors editPageText): count matching top-level
                // blocks first, so a non-unique fragment cannot silently replace the
                // wrong block (e.g. text that also appears inside a callout/table).
                const matches = doc.content.filter((b) => blockText(b).includes(opts.replaceText));
                if (matches.length === 0) {
                    throw new Error(`replaceText not found: "${opts.replaceText}"`);
                }
                if (matches.length > 1) {
                    throw new Error(`replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`);
                }
                const idx = doc.content.findIndex((b) => blockText(b).includes(opts.replaceText));
                // Data-loss guard: replaceText swaps the WHOLE top-level block, so if
                // the fragment only appears nested inside a container (table, callout,
                // list, blockquote) the entire structure would be destroyed. Refuse
                // when the matched block is a container rather than a leaf
                // paragraph/heading and point the caller at a safer tool.
                const CONTAINER_TYPES = new Set([
                    "table",
                    "callout",
                    "bulletList",
                    "orderedList",
                    "taskList",
                    "blockquote",
                ]);
                const matchedBlock = doc.content[idx];
                if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) {
                    throw new Error(`replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` +
                        `Use afterText to insert near it, or update_page_json for surgical edits.`);
                }
                doc.content.splice(idx, 1, node);
                placement = "replaced";
            }
            else if (opts.afterText) {
                // Ambiguity guard (mirrors editPageText): refuse a non-unique fragment.
                const matches = doc.content.filter((b) => blockText(b).includes(opts.afterText));
                if (matches.length === 0) {
                    throw new Error(`afterText not found: "${opts.afterText}"`);
                }
                if (matches.length > 1) {
                    throw new Error(`afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`);
                }
                const idx = doc.content.findIndex((b) => blockText(b).includes(opts.afterText));
                doc.content.splice(idx + 1, 0, node);
                placement = "after";
            }
            else {
                doc.content.push(node);
                placement = "appended";
            }
            return doc;
        });
        return {
            success: true,
            pageId,
            attachmentId: up.attachmentId,
            src: up.src,
            placement,
            verify: mutation.verify,
        };
    }
    /**
     * Replace an existing image in a page with a new image fetched from a web
     * (http/https) URL. Uploads the new file as a brand-new attachment, which
     * yields a fresh clean URL that both renders correctly and busts browser
     * caches (the URL changed). Finds every image node
     * whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested
     * in callouts/tables) and repoints its src/attachmentId/size, preserving
     * comments, alignment and alt. Operates on the live collab document so comments
     * and concurrent edits are preserved. Throws if no matching image is found.
     *
     * The OLD attachment is left in place as an unreferenced orphan: Docmost
     * exposes NO HTTP API to delete a single content attachment (verified against
     * the attachment controller/service and by probing the live API — deletion
     * happens only by cascade when the page, space or user is removed). This is the
     * same outcome as Docmost's own editor when an image is removed/replaced.
     * In-place byte overwrite is deliberately NOT used because some Docmost
     * versions corrupt the attachment (HTTP 500) when its bytes are overwritten.
     */
    async replaceImage(pageId, oldAttachmentId, url, opts = {}) {
        const collabToken = await this.getCollabTokenWithReauth();
        // Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
        // Previously the scan and the write were two separate mutatePageContent
        // calls, each acquiring + releasing the lock, with the upload happening in
        // the UNLOCKED gap between them. A concurrent op could interleave there: it
        // could remove the target image so the write pass matches nothing, leaving
        // the freshly-uploaded attachment as an un-deletable orphan (Docmost has no
        // API to delete a single content attachment). Acquiring the lock once and
        // using the non-locking collab helper inside (the per-page mutex is NOT
        // reentrant, so the self-locking mutatePageContent would deadlock here)
        // closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
        // and does not touch the page lock, so it is safe to call while held.
        return withPageLock(pageId, async () => {
            // STEP 1: read-only live check. Scan the live document for any image node
            // matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
            // throws without ever creating an orphan attachment.
            let matchFound = false;
            const scan = (nodes) => {
                for (const node of nodes) {
                    if (!node)
                        continue;
                    if (node.type === "image" &&
                        node.attrs &&
                        node.attrs.attachmentId === oldAttachmentId) {
                        matchFound = true;
                    }
                    if (Array.isArray(node.content))
                        scan(node.content);
                }
            };
            await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
                matchFound = false; // reset per-transform (collab may retry the read).
                const doc = liveDoc && liveDoc.type === "doc"
                    ? liveDoc
                    : { type: "doc", content: [] };
                if (Array.isArray(doc.content))
                    scan(doc.content);
                return null; // read-only: never write on the check pass.
            });
            if (!matchFound) {
                throw new Error(`replace_image: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`);
            }
            // STEP 2: a match exists — upload the new file as a FRESH attachment (new
            // id, new clean URL) and repoint every matching node in a second pass.
            // Still inside the SAME lock, so no other op can have changed the page
            // since the scan.
            const up = await this.uploadImage(pageId, url);
            let replaced = 0;
            // Swap the source of one image node, preserving align/alt/title/geometry.
            const repoint = (node) => {
                node.attrs = {
                    ...node.attrs,
                    src: up.src,
                    attachmentId: up.attachmentId,
                    // Default to null when fileSize is unknown so the attr is never
                    // undefined.
                    size: up.fileSize ?? null,
                };
                if (opts.align)
                    node.attrs.align = opts.align;
                if (opts.alt !== undefined)
                    node.attrs.alt = opts.alt;
                replaced++;
            };
            // Recursively repoint every image node (incl. ones nested in callouts/tables).
            const walk = (nodes) => {
                for (const node of nodes) {
                    if (!node)
                        continue;
                    if (node.type === "image" &&
                        node.attrs &&
                        node.attrs.attachmentId === oldAttachmentId) {
                        repoint(node);
                    }
                    if (Array.isArray(node.content))
                        walk(node.content);
                }
            };
            const mutation = await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
                // Reset per-transform so collab retries recompute cleanly (no double-count).
                replaced = 0;
                const doc = liveDoc && liveDoc.type === "doc"
                    ? liveDoc
                    : { type: "doc", content: [] };
                if (!Array.isArray(doc.content))
                    doc.content = [];
                walk(doc.content);
                if (replaced === 0)
                    return null; // no match -> skip the write entirely
                return doc;
            });
            // KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no
            // text/mark change) may still report verify.changed === false, because the
            // text+marks+integrity-count model in summarizeChange does not inspect
            // image `src`/attachmentId attributes. That is acceptable here — the
            // replace is confirmed by `replaced` below, and verify is supplementary.
            if (replaced === 0) {
                // The pass-1 SCAN found the target (matchFound was true) and we already
                // uploaded the new attachment, but pass-2 matched nothing — a concurrent
                // editor must have removed the node between the two passes. Do NOT throw
                // here (that would leak the just-uploaded attachment AND report failure);
                // instead report success with the upload flagged as an unreferenced
                // orphan so the caller knows. (The early throw above still covers the
                // case where pass-1 finds nothing, before any upload happens.)
                return {
                    success: true,
                    replaced: 0,
                    pageId,
                    oldAttachmentId,
                    newAttachmentId: up.attachmentId,
                    src: up.src,
                    orphanedAttachmentId: up.attachmentId,
                    warning: "target image was removed concurrently; uploaded attachment is unreferenced",
                    verify: mutation.verify,
                };
            }
            return {
                success: true,
                pageId,
                replaced,
                oldAttachmentId,
                newAttachmentId: up.attachmentId,
                src: up.src,
                verify: mutation.verify,
            };
        });
    }
    // --- Page history / diff / transform ---
    /**
     * List the saved versions (history snapshots) of a page, newest first.
     * Docmost auto-snapshots on every save. Returns one cursor-paginated page of
     * results: `{ items, nextCursor }`. The history record's id field is `id`.
     */
    async listPageHistory(pageId, cursor) {
        await this.ensureAuthenticated();
        const payload = { pageId };
        if (cursor)
            payload.cursor = cursor;
        const response = await this.client.post("/pages/history", payload);
        const data = response.data?.data ?? response.data;
        return {
            items: data?.items ?? [],
            nextCursor: data?.meta?.nextCursor ?? null,
        };
    }
    /**
     * Fetch a single page-history version including its lossless ProseMirror
     * `content`. The version also carries pageId/title/createdAt.
     */
    async getPageHistory(historyId) {
        await this.ensureAuthenticated();
        const response = await this.client.post("/pages/history/info", {
            historyId,
        });
        return response.data?.data ?? response.data;
    }
    /**
     * "Restore" a version: Docmost has NO restore endpoint, so we take the
     * version's `content` and write it as the page's current content via the live
     * collab path (which itself creates a new history snapshot). Returns the
     * affected pageId and the source historyId.
     */
    async restorePageVersion(historyId) {
        await this.ensureAuthenticated();
        const version = await this.getPageHistory(historyId);
        if (!version ||
            !version.pageId ||
            !version.content ||
            typeof version.content !== "object") {
            throw new Error(`restore_page_version: history ${historyId} has no usable content`);
        }
        // Defense-in-depth: sanitize URLs in the restored content (parity with the
        // JSON write path) before writing it back.
        this.validateDocUrls(version.content);
        const collabToken = await this.getCollabTokenWithReauth();
        const mutation = await mutatePageContent(version.pageId, collabToken, this.apiUrl, () => version.content);
        return {
            pageId: version.pageId,
            restoredFrom: historyId,
            verify: mutation.verify,
        };
    }
    /**
     * Diff two versions of a page and return a Docmost-equivalent change set.
     * `from`/`to` each resolve to a ProseMirror doc:
     *   - null / undefined / "current" -> the page's CURRENT content;
     *   - any other string             -> that historyId's content.
     * Returns the diff plus the resolved version metadata for each side.
     */
    async diffPageVersions(pageId, from, to) {
        await this.ensureAuthenticated();
        const isCurrent = (v) => v == null || v === "" || v === "current";
        const resolveSide = async (v) => {
            if (isCurrent(v)) {
                const raw = await this.getPageRaw(pageId);
                return {
                    doc: raw.content || { type: "doc", content: [] },
                    meta: {
                        kind: "current",
                        pageId,
                        title: raw.title,
                        updatedAt: raw.updatedAt,
                    },
                };
            }
            const version = await this.getPageHistory(v);
            return {
                doc: version.content || { type: "doc", content: [] },
                meta: {
                    kind: "history",
                    historyId: version.id,
                    pageId: version.pageId,
                    title: version.title,
                    createdAt: version.createdAt,
                },
            };
        };
        const fromSide = await resolveSide(from);
        const toSide = await resolveSide(to);
        const diff = diffDocs(fromSide.doc, toSide.doc);
        return { from: fromSide.meta, to: toSide.meta, diff };
    }
    /**
     * Edit a page by running an arbitrary user-supplied JS transform against the
     * live document, with a diff preview + page-history safety net.
     *
     * The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
     * sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
     * a 5s timeout, and NO access to require/process/fs/network. It must return a
     * `{ type: "doc" }` node, which is validated structurally before any write.
     *
     * `ctx` exposes:
     *   - comments: the page's comments (fetched before the live read);
     *   - log: an array the transform can push diagnostics to (via console.log);
     *   - consume(id): mark a comment id as consumed (for deleteComments);
     *   - helpers: the transforms.ts primitives + commentsToFootnotes.
     *
     * Footnote convention used by the helpers: footnote markers are plain "[N]"
     * text in the body, and the notes are an orderedList under a heading whose
     * text is "Примечания переводчика".
     *
     * dryRun (default true): read the page's current content, run the transform,
     * and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
     * Otherwise the transform runs atomically inside mutatePageContent, optionally
     * deletes consumed comments, and returns the new historyId + diff + log.
     */
    async transformPage(pageId, transformJs, opts = {}) {
        const dryRun = opts.dryRun ?? true;
        const deleteComments = opts.deleteComments ?? false;
        await this.ensureAuthenticated();
        const comments = await this.listComments(pageId);
        // ctx handed to the sandbox. consume() records ids; helpers are the pure
        // transform primitives. log is captured from console.log inside the sandbox.
        const ctx = {
            comments,
            log: [],
            consumed: new Set(),
            consume(id) {
                this.consumed.add(id);
            },
            helpers: {
                blockText,
                walk,
                getList,
                insertMarkerAfter,
                setCalloutRange,
                noteItem,
                mdToInlineNodes,
                commentsToFootnotes,
            },
        };
        // Captured oldDoc / newDoc for the diff (set inside runTransform).
        let oldDoc;
        let newDoc;
        // SYNCHRONOUS transform runner — safe to call inside mutatePageContent's
        // onSynced (no await between the live read and the write).
        const runTransform = (liveDoc) => {
            oldDoc = structuredClone(liveDoc);
            const sandbox = {
                doc: structuredClone(liveDoc),
                ctx,
                structuredClone,
                console: {
                    log: (...a) => ctx.log.push(a.map((x) => String(x)).join(" ")),
                },
            };
            // Wrap the provided string in parentheses so both an expression-arrow
            // (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a
            // fresh context with no require/process/module so the transform cannot
            // touch fs/network/process. 5s wall-clock timeout.
            let fn;
            try {
                fn = vm.runInNewContext("(" + transformJs + ")", sandbox, {
                    timeout: 5000,
                });
            }
            catch (e) {
                throw new Error(`transform did not compile: ${e?.message ?? e}`);
            }
            if (typeof fn !== "function") {
                throw new Error("transform must evaluate to a function (doc, ctx) => doc");
            }
            const result = vm.runInNewContext("f(d, c)", { f: fn, d: sandbox.doc, c: ctx }, { timeout: 5000 });
            if (!result ||
                typeof result !== "object" ||
                result.type !== "doc" ||
                !Array.isArray(result.content)) {
                throw new Error('transform must return a ProseMirror doc node ({ type:"doc", content:[...] })');
            }
            // Validate the returned doc before it can be written.
            this.validateDocStructure(result);
            this.validateDocUrls(result);
            newDoc = result;
            return result;
        };
        if (dryRun) {
            // Preview only: run against the current REST snapshot, never open the
            // socket. oldDoc/newDoc are captured by runTransform.
            const raw = await this.getPageRaw(pageId);
            const current = raw.content || { type: "doc", content: [] };
            runTransform(current);
            // Run an independent Yjs-encodability check (same sanitize + schema as the
            // apply path), so the preview fails with the same descriptive error when
            // the doc is not encodable instead of returning a misleadingly-green diff.
            assertYjsEncodable(newDoc);
            return {
                pushed: false,
                diff: diffDocs(oldDoc, newDoc),
                log: ctx.log,
            };
        }
        // Apply atomically against the live doc.
        const collabToken = await this.getCollabTokenWithReauth();
        const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform);
        // Optionally delete consumed comments (best-effort; a delete failure must
        // not undo the successful write).
        const deletedComments = [];
        if (deleteComments) {
            for (const id of ctx.consumed) {
                try {
                    await this.deleteComment(id);
                    deletedComments.push(id);
                }
                catch (e) {
                    if (process.env.DEBUG) {
                        console.error(`transform: failed to delete comment ${id}:`, e);
                    }
                }
            }
        }
        // Fetch the newest historyId (Docmost snapshots on the write above).
        let historyId = null;
        try {
            const hist = await this.listPageHistory(pageId);
            historyId = hist.items?.[0]?.id ?? null;
        }
        catch (e) {
            if (process.env.DEBUG) {
                console.error("transform: failed to fetch history id:", e);
            }
        }
        return {
            pushed: true,
            historyId,
            diff: diffDocs(oldDoc, newDoc),
            deletedComments,
            log: ctx.log,
            verify: mutation.verify,
        };
    }
}
