import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";

// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
export { DocmostClient } from "./client.js";
export type { DocmostMcpConfig } from "./client.js";

// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
// service can read it off the loaded module (it cannot import the ESM package's
// internals directly; it goes through loadDocmostMcp()).
export { SHARED_TOOL_SPECS } from "./tool-specs.js";
export type { SharedToolSpec } from "./tool-specs.js";

// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(
  readFileSync(join(__dirname, "../package.json"), "utf-8"),
);
const VERSION = packageJson.version;

// Configuration for an MCP server instance is the DocmostMcpConfig union
// (credentials OR getToken) defined and re-exported above. The factory below is
// fully side-effect-free on import: it reads no environment variables and opens
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
// (http.ts) supply this config and own the process/transport lifecycle.

// --- Modern McpServer Implementation ---

// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
const SERVER_INSTRUCTIONS =
  "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
  "Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
  "Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
  "Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";

// Helper to format JSON responses
const jsonContent = (data: any) => ({
  content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});

/**
 * Create a fully configured Docmost MCP server. Side-effect-free: it does not
 * read environment variables and does not connect any transport — the caller
 * decides how to expose it (stdio or HTTP). The client talks to Docmost over
 * REST + the collaboration WebSocket using the provided service-account
 * credentials and auto-re-authenticates.
 */
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
  // Pass the whole config union through: the client branches internally on
  // credentials vs. getToken, so both the external /mcp (creds) and the
  // internal per-user (getToken) paths are wired here unchanged.
  const docmostClient = new DocmostClient(config);

  const server = new McpServer(
    {
      name: "docmost-mcp",
      version: VERSION,
    },
    { instructions: SERVER_INSTRUCTIONS },
  );

  // Register a tool from the shared, zod-agnostic spec registry. The spec owns
  // the canonical name + model-facing description + (optional) schema builder;
  // only the execute body is supplied per call. buildShape is invoked with THIS
  // package's zod (v3); the in-app layer passes its own zod (v4).
  //
  // The spec's schema builder returns a plain ZodRawShape (Record<string,
  // unknown> in the shared module since it must stay zod-agnostic), so the
  // McpServer.registerTool overloads cannot infer the execute arg's shape from
  // it. We type `execute` loosely and cast the call through `any`; runtime
  // behaviour is unchanged — each execute body destructures the same fields the
  // builder declares.
  const registerShared = (
    spec: SharedToolSpec,
    execute: (args: any) => Promise<{ content: { type: "text"; text: string }[] }>,
  ) =>
    (server.registerTool as any)(
      spec.mcpName,
      spec.buildShape
        ? { description: spec.description, inputSchema: spec.buildShape(z) }
        : { description: spec.description },
      execute,
    );

  // Tool: get_workspace
  registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
    const workspace = await docmostClient.getWorkspace();
    return jsonContent(workspace);
  });

  // Tool: list_spaces
  registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
    const spaces = await docmostClient.getSpaces();
    return jsonContent(spaces);
  });

// Tool: list_pages
server.registerTool(
  "list_pages",
  {
    description:
      "List most recent pages in a space ordered by updatedAt (descending). " +
      "Returns a bounded list (default 50, max 100) — use search for lookups " +
      "in large spaces. Pass tree:true (with spaceId) to instead get the " +
      "space's full page hierarchy as a nested tree.",
    inputSchema: {
      spaceId: z.string().optional(),
      limit: z
        .number()
        .int()
        .min(1)
        .max(100)
        .optional()
        .describe("Max pages to return (default 50, max 100)"),
      tree: z
        .boolean()
        .optional()
        .describe(
          "When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
        ),
    },
  },
  async ({ spaceId, limit, tree }) => {
    const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
    return jsonContent(result);
  },
);

// Tool: get_page
server.registerTool(
  "get_page",
  {
    description:
      "Get page details with content converted to Markdown. The conversion is " +
      "LOSSY (block ids, exact table/callout structure are approximated); for a " +
      "lossless representation use get_page_json.",
    inputSchema: {
      pageId: z.string().min(1),
    },
  },
  async ({ pageId }) => {
    const page = await docmostClient.getPage(pageId);
    return jsonContent(page);
  },
);

// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
  const page = await docmostClient.getPageJson(pageId);
  return jsonContent(page);
});

// Tool: get_outline
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
  const result = await docmostClient.getOutline(pageId);
  return jsonContent(result);
});

// Tool: get_node
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
  const result = await docmostClient.getNode(pageId, nodeId);
  return jsonContent(result);
});

// Tool: table_get
server.registerTool(
  "table_get",
  {
    description:
      "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
      "cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
      "get_outline, or any block id inside the table. Use cellIds with " +
      "patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
      "width; ragged tables may vary per row, so use the per-row length of " +
      "`cells` for each row.",
    inputSchema: {
      pageId: z.string().min(1),
      table: z.string().min(1),
    },
  },
  async ({ pageId, table }) => {
    const result = await docmostClient.getTable(pageId, table);
    return jsonContent(result);
  },
);

// Tool: table_insert_row
server.registerTool(
  "table_insert_row",
  {
    description:
      "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
      "a block id inside it. `cells` = text per column (padded to the table's " +
      "column count; error if more cells than columns). `index` = 0-based " +
      "insert position (0 inserts before the header); omit to append at the end.",
    inputSchema: {
      pageId: z.string().min(1),
      table: z.string().min(1),
      cells: z.array(z.string()),
      index: z.number().int().optional(),
    },
  },
  async ({ pageId, table, cells, index }) => {
    const result = await docmostClient.tableInsertRow(
      pageId,
      table,
      cells,
      index,
    );
    return jsonContent(result);
  },
);

// Tool: table_delete_row
server.registerTool(
  "table_delete_row",
  {
    description:
      "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
      "a block id inside it). Refuses to delete the table's only row. An " +
      "out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
      "and the next row becomes the new header.",
    inputSchema: {
      pageId: z.string().min(1),
      table: z.string().min(1),
      index: z.number().int(),
    },
  },
  async ({ pageId, table, index }) => {
    const result = await docmostClient.tableDeleteRow(pageId, table, index);
    return jsonContent(result);
  },
);

// Tool: table_update_cell
server.registerTool(
  "table_update_cell",
  {
    description:
      "Set the plain-text content of cell [row,col] (0-based) in a table " +
      "(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
      "content with a single text paragraph; for rich formatting use patch_node " +
      "on the cell's paragraph id from table_get.",
    inputSchema: {
      pageId: z.string().min(1),
      table: z.string().min(1),
      row: z.number().int(),
      col: z.number().int(),
      text: z.string(),
    },
  },
  async ({ pageId, table, row, col, text }) => {
    const result = await docmostClient.tableUpdateCell(
      pageId,
      table,
      row,
      col,
      text,
    );
    return jsonContent(result);
  },
);

// Tool: create_page
server.registerTool(
  "create_page",
  {
    description:
      "Create a new page with content (automatically moves it to the correct hierarchy).",
    inputSchema: {
      title: z.string().min(1).describe("Title of the page"),
      content: z.string().min(1).describe("Markdown content"),
      spaceId: z.string().min(1),
      parentPageId: z
        .string()
        .optional()
        .describe("Optional parent page ID to nest under"),
    },
  },
  async ({ title, content, spaceId, parentPageId }) => {
    const result = await docmostClient.createPage(
      title,
      content,
      spaceId,
      parentPageId,
    );
    return jsonContent(result);
  },
);

// Tool: update_page_json
server.registerTool(
  "update_page_json",
  {
    description:
      "Replace a page's content with a raw ProseMirror JSON document " +
      "(lossless write: preserves the block ids, callouts, tables and " +
      "attributes you pass in). Typical flow: get_page_json -> modify the " +
      "JSON -> update_page_json. Keep existing node ids intact so heading " +
      "anchors and history stay stable. Minimal full-doc example: " +
      '{"type":"doc","content":[{"type":"paragraph","content":' +
      '[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
      "JSON string (both accepted), and is OPTIONAL: omit it to update only " +
      "the title (though prefer rename_page for a title-only change). " +
      "Supplying neither content nor title is an error.",
    inputSchema: {
      pageId: z.string().min(1).describe("ID of the page to update"),
      content: z
        .any()
        .optional()
        .describe(
          'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
            "JSON string). Omit to rename only.",
        ),
      title: z.string().optional().describe("Optional new title"),
    },
  },
  async ({ pageId, content, title }) => {
    // Only parse/validate the document when it was actually supplied; when it
    // is omitted, pass it straight through so the client performs a title-only
    // (or no-op) update.
    let doc;
    if (content === undefined || content === null) {
      doc = undefined;
    } else {
      // String -> JSON.parse (throwing on invalid); object passes through.
      doc = parseNodeArg(content, "content was a string but not valid JSON");
    }
    const result = await docmostClient.updatePageJson(pageId, doc, title);
    return jsonContent(result);
  },
);

// Tool: export_page_markdown
server.registerTool(
  "export_page_markdown",
  {
    description:
      "Export a page to a single self-contained, lossless Docmost-flavoured " +
      "Markdown file (custom extensions): YAML-free meta header, body with " +
      "inline comment anchors and diagrams, and a trailing comments-thread " +
      "block. Designed for a download -> edit body -> import_page_markdown " +
      "round-trip that preserves everything, including comment highlights. " +
      "Comment THREADS are preserved in the file but are not re-pushed to the " +
      "server on import.",
    inputSchema: {
      pageId: z.string().min(1),
    },
  },
  async ({ pageId }) => {
    const md = await docmostClient.exportPageMarkdown(pageId);
    return { content: [{ type: "text" as const, text: md }] };
  },
);

// Tool: import_page_markdown
registerShared(
  SHARED_TOOL_SPECS.importPageMarkdown,
  async ({ pageId, markdown }) => {
    const res = await docmostClient.importPageMarkdown(pageId, markdown);
    return jsonContent(res);
  },
);

// Tool: copy_page_content
registerShared(
  SHARED_TOOL_SPECS.copyPageContent,
  async ({ sourcePageId, targetPageId }) => {
    const result = await docmostClient.copyPageContent(
      sourcePageId,
      targetPageId,
    );
    return jsonContent(result);
  },
);

// Tool: rename_page
server.registerTool(
  "rename_page",
  {
    description:
      "Rename a page (change its title only) without touching or resending " +
      "its content.",
    inputSchema: {
      pageId: z.string().min(1).describe("ID of the page to rename"),
      title: z.string().min(1).describe("New title"),
    },
  },
  async ({ pageId, title }) => {
    const result = await docmostClient.renamePage(pageId, title);
    return jsonContent(result);
  },
);

// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
  const result = await docmostClient.editPageText(pageId, edits);
  return jsonContent(result);
});

// Tool: patch_node
server.registerTool(
  "patch_node",
  {
    description:
      "Replaces a single block identified by its attrs.id WITHOUT resending the " +
      "whole document. Get the block id from get_page_json, then pass a " +
      "ProseMirror node to put in its place. Example node: a paragraph " +
      '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
      'heading {"type":"heading","attrs":{"level":2},"content":' +
      '[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
      '{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
      "JSON object or a JSON string (both accepted). Cheaper and safer than " +
      "update_page_json for one-block structural edits.",
    inputSchema: {
      pageId: z.string().min(1),
      nodeId: z.string().min(1),
      node: z
        .any()
        .describe(
          "ProseMirror node to put in place of the node with this id, e.g. " +
            '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
            "JSON object or JSON string both accepted.",
        ),
    },
  },
  async ({ pageId, nodeId, node }) => {
    const parsedNode = parseNodeArg(node);
    const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
    return jsonContent(result);
  },
);

// Tool: insert_node
server.registerTool(
  "insert_node",
  {
    description:
      "Insert a block before/after another block (by attrs.id or anchor text) " +
      "or append at the end. Get anchor block ids from get_page_json. Avoids " +
      "resending the whole document. Can also insert table structure: to add a " +
      "tableRow, pass a tableRow node with position before/after and anchor " +
      "INSIDE the target table — anchorNodeId of any block/cell in it, or " +
      "anchorText matching the table; to add a tableCell/tableHeader, use " +
      "anchorNodeId of a block inside the target row (anchorText only resolves " +
      "top-level blocks, so it cannot target a row). `anchorText` is matched " +
      "against the block's literal rendered plain text (no markdown); " +
      "markdown/emoji are tolerated as a fallback; prefer plain text or " +
      "anchorNodeId. Note: append is top-level " +
      "only and rejects structural table nodes. Example node: a paragraph " +
      '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
      'heading {"type":"heading","attrs":{"level":2},"content":' +
      '[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
      '{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
      "JSON object or a JSON string (both accepted).",
    inputSchema: {
      pageId: z.string().min(1),
      node: z
        .any()
        .describe(
          "ProseMirror node to insert, e.g. " +
            '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
            "JSON object or JSON string both accepted.",
        ),
      position: z.enum(["before", "after", "append"]),
      anchorNodeId: z.string().optional(),
      anchorText: z.string().optional(),
    },
  },
  async ({ pageId, node, position, anchorNodeId, anchorText }) => {
    const parsedNode = parseNodeArg(node);
    const result = await docmostClient.insertNode(pageId, parsedNode, {
      position,
      anchorNodeId,
      anchorText,
    });
    return jsonContent(result);
  },
);

// Tool: delete_node
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
  const result = await docmostClient.deleteNode(pageId, nodeId);
  return jsonContent(result);
});

// Tool: insert_image
server.registerTool(
  "insert_image",
  {
    description:
      "Download an image from a web (http/https) URL and insert it into " +
      "a page in one step. By default " +
      "appends the image at the end of the page. With replaceText, replaces the " +
      "first top-level block whose text contains that string (handy for " +
      'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
      "With afterText, inserts the image right after the first block containing " +
      "that string. Preserves all other block ids.",
    inputSchema: {
      pageId: z.string().min(1),
      imageUrl: z
        .string()
        .min(1)
        .describe("http(s) URL of the image to download and upload"),
      align: z.enum(["left", "center", "right"]).optional(),
      alt: z.string().optional(),
      replaceText: z
        .string()
        .optional()
        .describe(
          "Replace the first top-level block whose text contains this string with the image",
        ),
      afterText: z
        .string()
        .optional()
        .describe(
          "Insert the image right after the first top-level block whose text contains this string",
        ),
    },
  },
  async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
    const result = await docmostClient.insertImage(pageId, imageUrl, {
      align,
      alt,
      replaceText,
      afterText,
    });
    return jsonContent(result);
  },
);

// Tool: replace_image
server.registerTool(
  "replace_image",
  {
    description:
      "Replace an existing image on a page with a new image fetched from a web " +
      "(http/https) URL: uploads the new file as a NEW " +
      "attachment (fresh clean URL that renders and busts browser caches), then " +
      "repoints every image node referencing the old attachmentId (recursively, " +
      "incl. callouts/tables) via the live document, preserving comments, " +
      "alignment and alt. The old attachment is left as an unreferenced orphan " +
      "(Docmost has no API to delete a single attachment; it is removed only when " +
      "the page/space is deleted). In-place byte overwrite is avoided because some " +
      "Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
    inputSchema: {
      pageId: z.string().min(1),
      attachmentId: z
        .string()
        .min(1)
        .describe("attachmentId of the image currently in the page to replace"),
      imageUrl: z
        .string()
        .min(1)
        .describe("http(s) URL of the new image to download"),
      align: z.enum(["left", "center", "right"]).optional(),
      alt: z.string().optional(),
    },
  },
  async ({ pageId, attachmentId, imageUrl, align, alt }) => {
    const result = await docmostClient.replaceImage(
      pageId,
      attachmentId,
      imageUrl,
      {
        align,
        alt,
      },
    );
    return jsonContent(result);
  },
);

// Tool: share_page
server.registerTool(
  "share_page",
  {
    description:
      "Make a page publicly accessible (idempotent) and return its public " +
      "URL. The URL format is <app>/share/<key>/p/<slugId>.",
    inputSchema: {
      pageId: z.string().min(1).describe("ID of the page to share"),
      searchIndexing: z
        .boolean()
        .optional()
        .describe("Allow search engines to index the page (default true)"),
    },
  },
  async ({ pageId, searchIndexing }) => {
    const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
    return jsonContent(result);
  },
);

// Tool: unshare_page
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
  const result = await docmostClient.unsharePage(pageId);
  return jsonContent(result);
});

// Tool: list_shares
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
  const result = await docmostClient.listShares();
  return jsonContent(result);
});

// Tool: move_page
server.registerTool(
  "move_page",
  {
    description:
      "Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
    inputSchema: {
      pageId: z.string().min(1),
      parentPageId: z
        .string()
        .nullable()
        .optional()
        .describe(
          "Target parent page ID. Pass 'null' or empty string to move to root.",
        ),
      position: z
        .string()
        .min(5)
        .optional()
        .describe(
          "fractional-index position key; min 5 chars; omit to append at the end.",
        ),
    },
  },
  async ({ pageId, parentPageId, position }) => {
    const finalParentId =
      parentPageId === "" || parentPageId === "null" ? null : parentPageId;

    // Cheap cycle guard: a page cannot be moved directly under itself.
    // (Deeper descendant-cycle detection is intentionally out of scope.)
    if (finalParentId !== null && finalParentId === pageId) {
      throw new Error("cannot move a page under itself");
    }

    const result = await docmostClient.movePage(
      pageId,
      finalParentId || null,
      position,
    );

    // Require POSITIVE confirmation: the live /pages/move success shape is
    // exactly { success: true, status: 200 }. An empty body, a 204, or any odd
    // shape lacking success === true must NOT be reported as a successful move,
    // so we surface the raw API result instead of declaring success.
    if (!(result && typeof result === "object" && result.success === true)) {
      throw new Error(
        `Failed to move page ${pageId}: ${JSON.stringify(result)}`,
      );
    }

    return jsonContent({
      message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
      result,
    });
  },
);

// Tool: delete_page
server.registerTool(
  "delete_page",
  {
    description: "Delete a single page by ID.",
    inputSchema: {
      pageId: z.string().min(1),
    },
  },
  async ({ pageId }) => {
    await docmostClient.deletePage(pageId);
    return {
      content: [
        { type: "text" as const, text: `Successfully deleted page ${pageId}` },
      ],
    };
  },
);

// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---

// Tool: list_comments
server.registerTool(
  "list_comments",
  {
    description:
      "List all comments on a page (paginated). Content is returned as Markdown.",
    inputSchema: {
      pageId: z.string().describe("ID of the page"),
    },
  },
  async ({ pageId }) => {
    const comments = await docmostClient.listComments(pageId);
    return jsonContent(comments);
  },
);

// Tool: create_comment
server.registerTool(
  "create_comment",
  {
    description:
      "Create a new comment on a page. The comment is ALWAYS inline and is " +
      "anchored to (highlights) its `selection` text — there are no page-level " +
      "comments. Content is provided as Markdown and automatically converted. " +
      "A top-level comment REQUIRES an exact `selection`; if the selection " +
      "cannot be found in the page the call fails (no orphan comment is left). " +
      "Replies (parentCommentId set) inherit the parent's anchor and take no " +
      "selection.",
    inputSchema: {
      pageId: z.string().describe("ID of the page to comment on"),
      content: z.string().min(1).describe("Comment content in Markdown format"),
      selection: z
        .string()
        .min(1)
        // Enforce the documented 250-char cap to match the description above.
        .max(250)
        .optional()
        .describe(
          "EXACT contiguous text from a single paragraph/block to anchor the " +
            "comment on (<=250 chars). Required for a top-level comment; omit " +
            "only when replying via parentCommentId.",
        ),
      parentCommentId: z
        .string()
        .optional()
        .describe("Parent comment ID to create a reply (max 2 nesting levels)"),
    },
  },
  async ({ pageId, content, selection, parentCommentId }) => {
    if (!parentCommentId && (!selection || !selection.trim())) {
      throw new Error(
        "create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
      );
    }
    const result = await docmostClient.createComment(
      pageId,
      content,
      "inline",
      selection,
      parentCommentId,
    );
    return jsonContent(result);
  },
);

// Tool: update_comment
server.registerTool(
  "update_comment",
  {
    description:
      "Update an existing comment's content. Only the comment creator can " +
      "update it. Content is provided as Markdown.",
    inputSchema: {
      commentId: z.string().min(1).describe("ID of the comment to update"),
      content: z
        .string()
        .min(1)
        .describe("New comment content in Markdown format"),
    },
  },
  async ({ commentId, content }) => {
    const result = await docmostClient.updateComment(commentId, content);
    return jsonContent(result);
  },
);

// Tool: delete_comment
server.registerTool(
  "delete_comment",
  {
    description:
      "Delete a comment. Only the comment creator or space admin can delete it.",
    inputSchema: {
      commentId: z.string().min(1).describe("ID of the comment to delete"),
    },
  },
  async ({ commentId }) => {
    await docmostClient.deleteComment(commentId);
    return {
      content: [
        {
          type: "text" as const,
          text: `Successfully deleted comment ${commentId}`,
        },
      ],
    };
  },
);

// Tool: check_new_comments
server.registerTool(
  "check_new_comments",
  {
    description:
      "Check for new comments across pages in a space since a given timestamp. " +
      "Optionally scope to a page subtree (folder). Returns only comments " +
      "created after the specified time.",
    inputSchema: {
      spaceId: z.string().describe("Space ID to check for new comments"),
      since: z
        .string()
        .min(1)
        .describe(
          "ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
        ),
      parentPageId: z
        .string()
        .optional()
        .describe(
          "Optional root page ID to scope the check to a subtree (folder). " +
            "Only pages under this parent will be checked.",
        ),
    },
  },
  async ({ spaceId, since, parentPageId }) => {
    // Reject an unparseable timestamp up front: otherwise the comparison
    // against NaN silently treats every comment as "not new" and the tool
    // returns zero results without signalling the bad input.
    if (Number.isNaN(Date.parse(since))) {
      throw new Error(
        `Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
      );
    }
    const result = await docmostClient.checkNewComments(
      spaceId,
      since,
      parentPageId,
    );
    return jsonContent(result);
  },
);

// Tool: search
server.registerTool(
  "search",
  {
    description:
      "Search for pages and content. Results are bounded by `limit` " +
      "(default applied by the client, max 100).",
    inputSchema: {
      query: z.string().min(1).describe("Search query"),
      limit: z
        .number()
        .int()
        .min(1)
        .max(100)
        .optional()
        .describe("Max results to return (max 100)"),
    },
  },
  async ({ query, limit }) => {
    // The tool exposes no spaceId filter, so pass undefined for the client's
    // optional spaceId parameter and forward limit into its correct slot.
    const result = await docmostClient.search(query, undefined, limit);
    return jsonContent(result);
  },
);

// Tool: docmost_transform
server.registerTool(
  "docmost_transform",
  {
    description:
      "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
      "against its LIVE ProseMirror document, with a diff preview and page " +
      "history as the safety net. By default dryRun=true: returns a diff " +
      "preview WITHOUT writing. Set dryRun=false to apply (atomic, won't " +
      "clobber concurrent edits). `doc` is the lossless ProseMirror document " +
      "({type:'doc',content:[...]}); return a new doc of the same shape. " +
      "`ctx` gives you: comments (the page's comments, each {id, content " +
      "(markdown), selection, type}); log (array; console.log pushes to it); " +
      "consume(id) (mark a comment id as consumed — those are deleted when " +
      "deleteComments=true after a successful apply); and helpers: " +
      "blockText(node) (plain text), walk(node, fn) (depth-first over all " +
      "nodes incl. callouts/tables/lists), getList(doc, predicate) (find a " +
      "node even without attrs.id), insertMarkerAfter(doc, anchor, marker, " +
      "{beforeBlock}) (insert a plain unmarked text run after anchor, " +
      "mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " +
      "[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " +
      "fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " +
      "and commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " +
      "comments into numbered footnotes). Footnote convention: markers are " +
      "plain '[N]' text in the body; the notes are an orderedList under a " +
      "heading whose text is 'Примечания переводчика'. The transform runs " +
      "sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
      "{type:'doc'} node.",
    inputSchema: {
      pageId: z.string().min(1),
      transformJs: z
        .string()
        .min(1)
        .describe(
          "A JS function `(doc, ctx) => doc` (expression-arrow or " +
            "parenthesized function). It receives a clone of the live doc and " +
            "ctx (comments, log, consume(id), helpers: blockText/walk/getList/" +
            "insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" +
            "commentsToFootnotes) and must return a {type:'doc'} node.",
        ),
      dryRun: z
        .boolean()
        .optional()
        .default(true)
        .describe("Preview only (no write) when true (default)."),
      deleteComments: z
        .boolean()
        .optional()
        .default(false)
        .describe(
          "After a successful apply, delete every comment id passed to " +
            "ctx.consume(id).",
        ),
    },
  },
  async ({ pageId, transformJs, dryRun, deleteComments }) => {
    const result = await docmostClient.transformPage(pageId, transformJs, {
      dryRun,
      deleteComments,
    });
    return jsonContent(result);
  },
);

// Tool: diff_page_versions
registerShared(
  SHARED_TOOL_SPECS.diffPageVersions,
  async ({ pageId, from, to }) => {
    const result = await docmostClient.diffPageVersions(pageId, from, to);
    return jsonContent(result);
  },
);

// Tool: list_page_history
registerShared(
  SHARED_TOOL_SPECS.listPageHistory,
  async ({ pageId, cursor }) => {
    const result = await docmostClient.listPageHistory(pageId, cursor);
    return jsonContent(result);
  },
);

// Tool: restore_page_version
registerShared(
  SHARED_TOOL_SPECS.restorePageVersion,
  async ({ historyId }) => {
    const result = await docmostClient.restorePageVersion(historyId);
    return jsonContent(result);
  },
);

  return server;
}
