import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiGet, apiPost, getProjectId } from "../api-client.js";

/**
 * Vision tools for the MCP server — gives the Claude Code CLI agent
 * the ability to visually inspect construction drawings, isolate symbols,
 * and run the OpenCV auto-count pipeline.
 *
 * The agent workflow for counting symbols:
 *   1. listDrawingPages   → discover what drawings exist in the project
 *   2. renderDrawingPage  → see the full page as an image
 *   3. zoomDrawingRegion  → zoom into a specific area to inspect small symbols
 *   4. countSymbols       → run CV pipeline with a precise bounding box
 *   5. saveCountAsAnnotations → persist results as takeoff annotations
 */

const boundingBoxSchema = {
  x: z.coerce.number().describe("X coordinate of top-left corner (pixels from left, in the rendered image coordinate space)"),
  y: z.coerce.number().describe("Y coordinate of top-left corner (pixels from top)"),
  width: z.coerce.number().describe("Width of region in pixels"),
  height: z.coerce.number().describe("Height of region in pixels"),
  imageWidth: z.coerce.number().describe("Total width of the rendered image this bbox refers to"),
  imageHeight: z.coerce.number().describe("Total height of the rendered image this bbox refers to"),
};

const drawingAnalysisPresetSchema = z.enum([
  "generic",
  "mechanical_piping",
  "plumbing",
  "fire_protection",
  "ductwork",
  "electrical",
  "civil_linear",
  "structural",
]);

function imageBase64(dataUrl: unknown) {
  const match = String(dataUrl ?? "").match(/^data:image\/png;base64,(.+)$/);
  return match?.[1] ?? null;
}

function asToolRecord(value: unknown): Record<string, any> {
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
}

function asToolArray(value: unknown): any[] {
  return Array.isArray(value) ? value : [];
}

function layerDiscipline(nameValue: unknown) {
  const name = String(nameValue ?? "").toLowerCase();
  if (/pipe|piping|valve|pump|mech|process|hydronic|steam|gas/.test(name)) return "mechanical_piping";
  if (/plumb|sanitary|domestic|storm|waste|vent/.test(name)) return "plumbing";
  if (/fire|sprinkler|fp-|f[-_ ]?protection/.test(name)) return "fire_protection";
  if (/duct|hvac|air|supply|return|exhaust/.test(name)) return "ductwork";
  if (/elec|power|light|conduit|cable|panel|device/.test(name)) return "electrical";
  if (/struct|steel|beam|column|foundation|rebar|anchor/.test(name)) return "structural";
  if (/civil|site|utility|storm|sewer|water|road|grade/.test(name)) return "civil_linear";
  if (/text|anno|note|dim|tag|label|title/.test(name)) return "annotation_text";
  if (/border|title|sheet/.test(name)) return "sheet_border_title";
  return "unknown";
}

function bboxFromAny(value: unknown) {
  const record = asToolRecord(value);
  const bbox = asToolRecord(record.bbox ?? record.rect ?? value);
  const x = Number(bbox.x ?? record.x);
  const y = Number(bbox.y ?? record.y);
  const width = Number(bbox.width ?? bbox.w ?? record.width ?? record.w);
  const height = Number(bbox.height ?? bbox.h ?? record.height ?? record.h);
  if (![x, y, width, height].every(Number.isFinite)) return null;
  return { x, y, width, height };
}

function bboxIntersects(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) {
  return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
}

function paddedRegion(region: { x: number; y: number; width: number; height: number; imageWidth: number; imageHeight: number }, paddingRatio: number) {
  const padX = region.width * paddingRatio;
  const padY = region.height * paddingRatio;
  const x = Math.max(0, region.x - padX);
  const y = Math.max(0, region.y - padY);
  const maxWidth = Math.max(1, region.imageWidth - x);
  const maxHeight = Math.max(1, region.imageHeight - y);
  return {
    x: Math.round(x),
    y: Math.round(y),
    width: Math.round(Math.min(maxWidth, region.width + padX * 2)),
    height: Math.round(Math.min(maxHeight, region.height + padY * 2)),
    imageWidth: region.imageWidth,
    imageHeight: region.imageHeight,
  };
}

function isIgnoredSourceDocument(fileName: unknown) {
  const name = String(fileName ?? "").toLowerCase();
  return /(^|\/)__macosx(\/|$)|(^|\/)\._|(^|\/)\.ds_store$|(^|\/)thumbs\.db$/.test(name);
}

function isPdfSourceDocument(doc: any) {
  const fileType = String(doc?.fileType ?? "").trim().toLowerCase();
  const fileName = String(doc?.fileName ?? doc?.name ?? "").trim().toLowerCase();
  return !isIgnoredSourceDocument(fileName) && (fileType === "application/pdf" || fileType === "pdf" || fileName.endsWith(".pdf"));
}

/** Helper: save an array of matches as Pickup records via the API */
async function saveMatchesAsAnnotations(opts: {
  documentId: string;
  pageNumber: number;
  matches: Array<{ rect: { x: number; y: number; width?: number; height?: number }; confidence: number; text?: string; method?: string }>;
  label: string;
  color: string;
  templateImage?: string;
}): Promise<{ savedCount: number; errors: string[] }> {
  const projectId = getProjectId();
  const errors: string[] = [];
  let savedCount = 0;

  for (const match of opts.matches) {
    try {
      await apiPost(`/api/takeoff/${projectId}/annotations`, {
        documentId: opts.documentId,
        pageNumber: opts.pageNumber,
        annotationType: "count",
        label: opts.label,
        color: opts.color,
        points: [{ x: match.rect.x, y: match.rect.y }],
        measurement: { value: opts.matches.length, unit: "count" },
        metadata: {
          createdBy: "agent",
          detection_method: match.method ?? "template_matching",
          confidence: match.confidence,
          matchText: match.text || undefined,
          templateImage: opts.templateImage || undefined,
        },
      });
      savedCount++;
    } catch (err) {
      errors.push(`Failed to save match at (${match.rect.x}, ${match.rect.y}): ${err instanceof Error ? err.message : String(err)}`);
    }
  }

  return { savedCount, errors };
}

export function registerVisionTools(server: McpServer) {

  // ── listDrawingPages ───────────────────────────────────────
  server.tool(
    "listDrawingPages",
    `List all PDF drawing documents in the current project with their page counts.

WHEN TO USE: Call this FIRST when starting any vision workflow. You need document IDs to use all other vision tools.

INPUTS: None required — automatically scoped to the current project.

OUTPUT: Array of documents with id, fileName, pageCount, and documentType. Use the "id" field as the documentId parameter for renderDrawingPage and other tools.

COMMON PITFALLS:
- Do NOT guess document IDs — always call this tool first to get valid IDs
- Documents with documentType "drawing" are construction drawings; "reference" docs may also be PDFs but aren't drawings
- pageCount may be null if the document hasn't been fully processed yet`,
    {},
    async () => {
      const { apiGet } = await import("../api-client.js");
      const workspace = await apiGet(`/projects/${getProjectId()}/workspace`);
      const docs = (workspace.sourceDocuments ?? workspace.documents ?? [])
        .filter((d: any) => isPdfSourceDocument(d))
        .map((d: any) => ({
          id: d.id,
          fileName: d.fileName ?? d.name,
          pageCount: d.pageCount ?? null,
          documentType: d.documentType,
        }));

      return {
        content: [{ type: "text" as const, text: JSON.stringify({ documents: docs, count: docs.length }, null, 2) }],
      };
    }
  );

  // ── renderDrawingPage ──────────────────────────────────────
  server.tool(
    "renderDrawingPage",
    `Render a construction drawing PDF page to an image so you can visually inspect it.

WHEN TO USE: This is the FIRST step whenever you need to look at a drawing — to find symbols, identify elements, understand the layout, or locate items to count. Always start here before zooming or counting.

INPUTS:
- documentId: Get this from listDrawingPages
- pageNumber: 1-based page number (default 1)
- dpi: Resolution — use 150 for overview/browsing, 200-300 for detailed inspection of small symbols. Higher DPI = larger image = more detail but more data

OUTPUT: Returns the page as a viewable PNG image, plus metadata JSON with imageWidth, imageHeight, pageWidth, pageHeight, pageCount, and dpi. The imageWidth/imageHeight define the coordinate space you MUST use for bounding boxes in zoomDrawingRegion and countSymbols.

COMMON PITFALLS:
- Always note the imageWidth and imageHeight from the response — you need these for bounding box coordinates
- Start with dpi=150 to get an overview, then zoom into specific areas with zoomDrawingRegion for detail
- A full-page render is only the overview. If this drawing drives scope or quantity, follow it with zoomDrawingRegion on the exact detail, symbol, schedule, dimension, or dense table area before you mark visual takeoff complete
- Large pages at 300 DPI produce very large images — only use high DPI when you need to see fine detail`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing (from listDrawingPages)"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number (1-based)"),
      dpi: z.coerce.number().min(72).max(300).default(150).describe("Resolution — 150 for overview, 200-300 for detail"),
    },
    async ({ documentId, pageNumber, dpi }) => {
      const result = await apiPost("/api/vision/render-page", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        dpi,
      });

      if (!result.success || !result.image) {
        return { content: [{ type: "text" as const, text: `Failed to render page: ${result.error ?? "unknown error"}` }] };
      }

      // Extract the raw base64 from the data URL
      const base64Match = (result.image as string).match(/^data:image\/png;base64,(.+)$/);
      if (!base64Match) {
        return { content: [{ type: "text" as const, text: "Render succeeded but image format was unexpected" }] };
      }

      return {
        content: [
          {
            type: "image" as const,
            data: base64Match[1],
            mimeType: "image/png" as const,
          },
          {
            type: "text" as const,
            text: JSON.stringify({
              imageWidth: result.width,
              imageHeight: result.height,
              pageWidth: result.pageWidth,
              pageHeight: result.pageHeight,
              pageCount: result.pageCount,
              pageNumber,
              dpi,
              note: "Use imageWidth and imageHeight as the coordinate space for bounding boxes in zoomDrawingRegion and countSymbols tools. If this sheet affects scope or quantity, follow this overview with a targeted zoomDrawingRegion before marking visual inspection complete. Use countSymbols only after identifying a tight representative symbol box.",
            }, null, 2),
          },
        ],
      };
    }
  );

  // ── zoomDrawingRegion ──────────────────────────────────────
  server.tool(
    "zoomDrawingRegion",
    `Zoom into a specific region of a drawing page at high resolution (300 DPI).

WHEN TO USE: After calling renderDrawingPage, use this to get a closer look at small symbols, crowded areas, or fine details that are hard to see at overview resolution. Essential for identifying the exact boundaries of small symbols before running countSymbols.

INPUTS:
- documentId: Same document ID used in renderDrawingPage
- pageNumber: Same page number
- region: Bounding box object with {x, y, width, height, imageWidth, imageHeight} — all coordinates MUST be in the renderDrawingPage coordinate space (use the imageWidth/imageHeight from that tool's output)

OUTPUT: A cropped, 300 DPI image of just the specified region, plus metadata. The zoomed image has its own dimensions (zoomedWidth/zoomedHeight) — do NOT use these as coordinates for countSymbols.

COMMON PITFALLS:
- Coordinates must come from renderDrawingPage's coordinate space, NOT from a previous zoom
- The zoomed image dimensions are different from the original — always use ORIGINAL coordinates for countSymbols
- Make the region large enough to see context around the symbol (add 20-30% padding)`,
    {
      documentId: z.string().describe("Document ID of the PDF/DXF/DWG drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number (1-based)"),
      region: z.object(boundingBoxSchema).describe("Region to zoom into — coordinates from a previous renderDrawingPage result"),
    },
    async ({ documentId, pageNumber, region }) => {
      const result = await apiPost("/api/vision/render-page", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        dpi: 300, // Always high-res for zoom
        region,
      });

      if (!result.success || !result.image) {
        return { content: [{ type: "text" as const, text: `Failed to zoom: ${result.error ?? "unknown error"}` }] };
      }

      const base64Match = (result.image as string).match(/^data:image\/png;base64,(.+)$/);
      if (!base64Match) {
        return { content: [{ type: "text" as const, text: "Zoom succeeded but image format was unexpected" }] };
      }

      return {
        content: [
          {
            type: "image" as const,
            data: base64Match[1],
            mimeType: "image/png" as const,
          },
          {
            type: "text" as const,
            text: JSON.stringify({
              zoomedWidth: result.width,
              zoomedHeight: result.height,
              originalRegion: region,
              note: "This is a high-res crop. To count symbols, use the ORIGINAL region coordinates (from renderDrawingPage's coordinate space) with countSymbols — not the zoomed image dimensions.",
            }, null, 2),
          },
        ],
      };
    }
  );

  // ── countSymbols ───────────────────────────────────────────
  server.tool(
    "countSymbols",
    `Run the OpenCV computer vision pipeline to count all occurrences of a symbol on a drawing page.

WHEN TO USE: After you have identified a symbol on a drawing page using renderDrawingPage (and optionally zoomDrawingRegion), use this tool to count how many times that symbol appears. You MUST provide an accurate bounding box around ONE clear example of the symbol.

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: Page to search (1-based)
- boundingBox: Tight bounding box around ONE example of the symbol — coordinates in renderDrawingPage's image coordinate space
- threshold: Match confidence (0.3-0.95). Default 0.65 works well. Lower = more matches but more false positives. Higher = fewer but more confident matches
- autoSave: If true, automatically saves all matches as Pickup records in the project takeoff. Default false — set to true when you want to persist count results for the user
- crossScale: If true, matches at multiple scales (0.75x-1.25x) to find the same symbol even when rendered at different sizes. ESSENTIAL for cross-document searches where different CAD sources produce different-sized symbols. ~6x slower but finds symbols that single-scale misses. Default false for same-page, set to true for cross-document.

OUTPUT: totalCount (number of matches), matches array with rect/confidence/text/method for each match, duration_ms, and any errors.

WORKFLOW:
1. Call renderDrawingPage to see the page (get imageWidth/imageHeight)
2. If the symbol is small, call zoomDrawingRegion to inspect it closely
3. Identify ONE clean, unobstructed example of the symbol
4. Call countSymbols with a tight bounding box around that example
5. If autoSave=true, matches are automatically saved as takeoff annotations
6. To count across ALL pages of a document, use countSymbolsAllPages instead
7. To count across ALL documents in the project, call countSymbolsAllPages for each document with crossScale=true

TIPS FOR ACCURACY:
- Draw the box tightly around the symbol with minimal surrounding whitespace
- Pick a clean, unobstructed example (not overlapping with other elements)
- Optimal threshold is 0.75 (proven across 5 real construction packages)
- If you get too many false positives, increase threshold to 0.80-0.85
- If you get too few matches, decrease threshold to 0.60-0.70
- For cross-document searches, ALWAYS set crossScale=true

COMMON PITFALLS:
- Bounding box coordinates MUST be in renderDrawingPage coordinate space (NOT zoomed coordinates)
- Providing too large a bounding box (with lots of surrounding context) reduces accuracy
- Providing too small a bounding box (clipping the symbol) also reduces accuracy
- Without crossScale, a template from one CAD source may not match a different source's rendering of the same symbol`,
    {
      documentId: z.string().describe("Document ID of the PDF/DXF/DWG drawing"),
      pageNumber: z.coerce.number().min(1).describe("Page number to search (1-based)"),
      boundingBox: z.object(boundingBoxSchema).describe("Bounding box around ONE example of the symbol to find — in renderDrawingPage coordinate space"),
      threshold: z.coerce.number().min(0.3).max(0.95).default(0.75).describe("Match confidence threshold. 0.75 is optimal (proven across 5 packages). Lower = more matches, higher = stricter"),
      crossScale: z.boolean().default(false).describe("Enable cross-scale matching (0.75x-1.25x). ESSENTIAL for cross-document searches. ~6x slower but catches different-sized renderings of the same symbol"),
      autoSave: z.boolean().default(false).describe("If true, automatically persist all matches as Pickup records"),
    },
    async ({ documentId, pageNumber, boundingBox, threshold, crossScale, autoSave }) => {
      const result = await apiPost("/api/vision/count-symbols", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        boundingBox,
        threshold,
        crossScale,
      });

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Symbol counting failed: ${result.error ?? JSON.stringify(result.errors)}` }] };
      }

      // Strip base64 images from matches to keep context lean
      const matches = (result.matches ?? []).map((m: any) => ({
        rect: m.rect,
        confidence: m.confidence,
        text: m.text || undefined,
        method: m.detection_method,
      }));

      // Auto-save as annotations if requested
      let saveResult: { savedCount: number; errors: string[] } | undefined;
      if (autoSave && matches.length > 0) {
        const totalCount = result.totalCount ?? matches.length;
        saveResult = await saveMatchesAsAnnotations({
          documentId,
          pageNumber,
          matches,
          label: `Auto Count: ${totalCount} symbols`,
          color: "#22c55e",
        });
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            totalCount: result.totalCount,
            documentId,
            pageNumber,
            threshold,
            duration_ms: result.duration_ms,
            matches,
            errors: result.errors?.length ? result.errors : undefined,
            ...(saveResult ? {
              autoSaved: true,
              savedCount: saveResult.savedCount,
              saveErrors: saveResult.errors.length ? saveResult.errors : undefined,
            } : {}),
          }, null, 2),
        }],
      };
    }
  );

  // ── countDrawingSymbol ───────────────────────────────────
  server.tool(
    "countDrawingSymbol",
    `Count a specific drawing symbol with the reusable DrawingDetection schema.

WHEN TO USE: This is the agent-facing version of the UI Auto Count workflow. Use renderDrawingPage/zoomDrawingRegion first, then pass one tight representative symbol box. The response includes both raw matches and detections shaped for saveDetectionsAsTakeoffMarks.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).describe("Page number to search"),
      boundingBox: z.object(boundingBoxSchema).describe("Tight box around one clean example of the symbol, in renderDrawingPage coordinate space"),
      label: z.string().default("Counted symbol").describe("Label to use for returned/saved detections"),
      threshold: z.coerce.number().min(0.3).max(0.95).default(0.75).describe("Match threshold"),
      crossScale: z.boolean().default(false).describe("Enable multi-scale matching"),
      autoSave: z.boolean().default(false).describe("Persist detections immediately as takeoff marks after counting"),
    },
    async ({ documentId, pageNumber, boundingBox, label, threshold, crossScale, autoSave }) => {
      const result = await apiPost("/api/vision/count-symbols", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        boundingBox,
        threshold,
        crossScale,
      });

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Symbol count failed: ${result.error ?? result.message ?? JSON.stringify(result)}` }] };
      }

      const matches = asToolArray(result.matches);
      const detections = matches.map((match, index) => {
        const rect = asToolRecord(match.rect);
        return {
          id: `symbol-${pageNumber}-${index + 1}`,
          kind: "symbol",
          label,
          annotationType: "count",
          color: "#22c55e",
          points: [{ x: Number(rect.x), y: Number(rect.y) }],
          confidence: Number(match.confidence ?? 0),
          source: "opencv-template-match",
          measurement: { value: 1, unit: "count" },
          metadata: {
            rect,
            method: match.detection_method ?? match.method ?? "template_matching",
            threshold,
            crossScale,
          },
        };
      });

      let saveResult: any = null;
      if (autoSave && detections.length > 0) {
        saveResult = await apiPost("/api/vision/save-detections-as-annotations", {
          projectId: getProjectId(),
          documentId,
          pageNumber,
          imageWidth: boundingBox.imageWidth,
          imageHeight: boundingBox.imageHeight,
          groupName: label,
          color: "#22c55e",
          detections,
        });
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            documentId,
            pageNumber,
            label,
            totalCount: result.totalCount ?? detections.length,
            threshold,
            crossScale,
            imageWidth: boundingBox.imageWidth,
            imageHeight: boundingBox.imageHeight,
            detections,
            rawMatches: matches.map((match) => ({
              rect: match.rect,
              confidence: match.confidence,
              method: match.detection_method ?? match.method,
              text: match.text || undefined,
            })),
            autoSaved: autoSave ? {
              savedCount: saveResult?.savedCount ?? 0,
              errors: saveResult?.errors?.length ? saveResult.errors : undefined,
            } : undefined,
          }, null, 2),
        }],
      };
    }
  );

  // ── saveCountAsAnnotations ─────────────────────────────────
  server.tool(
    "saveCountAsAnnotations",
    `Save symbol count results as takeoff annotations in the project. Creates a Pickup record for each match location, preserving the detection metadata.

WHEN TO USE: After running countSymbols (with autoSave=false), call this to persist the results if the user confirms they look correct. This is the manual alternative to countSymbols' autoSave parameter — useful when you want to review results before saving.

INPUTS:
- documentId: The document the matches were found in
- pageNumber: The page number the matches were found on
- matches: Array of match objects (copy from countSymbols output) — each needs at least rect.x, rect.y, and confidence
- label: Human-readable label for the annotations (e.g. "Fire Sprinkler Heads", "46 valve tags")
- color: Hex color for the annotation markers (default green #22c55e). Use colors that contrast with the drawing

OUTPUT: Number of successfully saved annotations and any errors.

COMMON PITFALLS:
- Make sure to pass the matches array from a countSymbols result — don't fabricate match data
- Each match becomes a separate annotation point on the takeoff
- The label should be descriptive — it shows up in the takeoff annotation list for the user
- Annotations are marked with metadata.createdBy = "agent" so users can distinguish them from manual annotations`,
    {
      documentId: z.string().describe("Document ID where matches were found"),
      pageNumber: z.coerce.number().min(1).describe("Page number where matches were found"),
      matches: z.array(z.object({
        rect: z.object({
          x: z.coerce.number().describe("X coordinate of the match"),
          y: z.coerce.number().describe("Y coordinate of the match"),
          width: z.coerce.number().optional().describe("Width of the matched region"),
          height: z.coerce.number().optional().describe("Height of the matched region"),
        }),
        confidence: z.coerce.number().describe("Match confidence (0-1)"),
        text: z.string().optional().describe("Detected text content if applicable"),
        method: z.string().optional().describe("Detection method used"),
      })).describe("Array of match results from countSymbols"),
      label: z.string().describe("Human-readable label for the annotations (e.g. 'Fire Sprinkler Heads')"),
      color: z.string().default("#22c55e").describe("Hex color for annotation markers (default green)"),
      templateImage: z.string().optional().describe("Optional base64 template image used for matching"),
    },
    async ({ documentId, pageNumber, matches, label, color, templateImage }) => {
      if (matches.length === 0) {
        return {
          content: [{ type: "text" as const, text: JSON.stringify({ savedCount: 0, message: "No matches to save" }, null, 2) }],
        };
      }

      const result = await saveMatchesAsAnnotations({
        documentId,
        pageNumber,
        matches,
        label,
        color,
        templateImage,
      });

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            savedCount: result.savedCount,
            totalMatches: matches.length,
            label,
            color,
            documentId,
            pageNumber,
            errors: result.errors.length ? result.errors : undefined,
          }, null, 2),
        }],
      };
    }
  );

  // ── countSymbolsAllPages ──────────────────────────────────
  server.tool(
    "countSymbolsAllPages",
    `Count a symbol across ALL pages of a multi-page document in one call. The server handles the per-page loop — much faster than calling countSymbols for each page individually.

WHEN TO USE: After finding a symbol on one page with countSymbols, use this to search the entire document for the same symbol. This is the "Search All Pages" capability.

INPUTS:
- documentId: Document ID from listDrawingPages
- boundingBox: Same bounding box used in countSymbols — coordinates in renderDrawingPage coordinate space
- threshold: Match confidence (default 0.75)
- crossScale: Set to true if pages may have different zoom levels or if the symbol might render at slightly different sizes across pages. ALWAYS use true when searching across documents from different sources.

OUTPUT: Per-page breakdown {pageNumber, totalCount, matches} plus grandTotal across all pages. Use this to see exactly which pages contain the symbol.

EXAMPLE USE CASES:
- "Count all valve tags across a 12-page P&ID set" → one call, get counts per page
- "How many fire sprinklers on each floor plan?" → search a multi-page architectural set
- "Find this symbol across all project drawings" → call this for each document from listDrawingPages with crossScale=true`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      boundingBox: z.object(boundingBoxSchema).describe("Bounding box of the symbol template — in renderDrawingPage coordinate space"),
      threshold: z.coerce.number().min(0.3).max(0.95).default(0.75).describe("Match confidence threshold (0.75 optimal)"),
      crossScale: z.boolean().default(false).describe("Enable cross-scale matching for different-sized renderings"),
    },
    async ({ documentId, boundingBox, threshold, crossScale }) => {
      const result = await apiPost("/api/vision/count-symbols-all-pages", {
        projectId: getProjectId(),
        documentId,
        boundingBox,
        threshold,
        crossScale,
      });

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Failed: ${JSON.stringify(result)}` }] };
      }

      // Summarize per-page results (strip match images)
      const pages = (result.pages ?? []).map((p: any) => ({
        pageNumber: p.pageNumber,
        totalCount: p.totalCount,
        matchCount: p.matches?.length ?? 0,
        errors: p.errors?.length ? p.errors : undefined,
      }));

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            grandTotal: result.grandTotal,
            pageCount: result.pageCount,
            documentId,
            threshold,
            crossScale,
            pages,
          }, null, 2),
        }],
      };
    }
  );

  // ── findSymbolCandidates ─────────────────────────────────
  server.tool(
    "findSymbolCandidates",
    `Automatically discover symbol-like elements on a drawing page using computer vision (connected component analysis). Returns bounding boxes of candidate symbols WITHOUT needing a template — useful for exploring a drawing when you don't know what symbols exist.

WHEN TO USE:
- When you need to find symbols on a drawing but don't have a specific template yet
- When a user asks "what symbols are on this page?" or "find all instruments"
- As a discovery step before running countSymbols — find candidates, visually verify them with zoomDrawingRegion, then count

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: Page to analyze (1-based, default 1)
- minSize: Minimum symbol dimension in pixels (default 20). Increase to skip text characters
- maxSize: Maximum symbol dimension in pixels (default 150). Decrease to skip large elements

OUTPUT: Array of candidates with {x, y, w, h, area, aspect} plus total count and image dimensions. Candidates are sorted by area (largest first) and filtered to exclude title block areas and borders.

TIPS:
- Set minSize=40 to skip individual text characters and find only real symbols
- Look for candidates that are roughly square (aspect 0.5-2.0) — these are often instruments, valves, or markers
- Use zoomDrawingRegion to visually inspect interesting candidates before counting
- Cluster candidates by size bucket to identify symbol types (e.g. all ~80x80 candidates are likely the same symbol type)`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number (1-based)"),
      minSize: z.coerce.number().default(20).describe("Minimum symbol dimension in pixels. Set to 40+ to skip text characters"),
      maxSize: z.coerce.number().default(150).describe("Maximum symbol dimension in pixels"),
    },
    async ({ documentId, pageNumber, minSize, maxSize }) => {
      const result = await apiPost("/api/vision/find-symbols", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        minSize,
        maxSize,
      });

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Failed: ${result.error ?? "unknown error"}` }] };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            total: result.total,
            imageWidth: result.imageWidth,
            imageHeight: result.imageHeight,
            candidates: (result.candidates ?? []).slice(0, 30), // Cap at 30 to avoid context bloat
            note: result.total > 30 ? `Showing top 30 of ${result.total} candidates` : undefined,
          }, null, 2),
        }],
      };
    }
  );

  // ── findDrawingSymbolCandidates ───────────────────────────
  server.tool(
    "findDrawingSymbolCandidates",
    `Find symbol-like regions on a drawing and optionally return thumbnails.

WHEN TO USE: Use this as the agent equivalent of symbol discovery before choosing one candidate to count. It returns reusable coordinates in the page image coordinate space plus optional high-resolution crop thumbnails for visual review.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number"),
      minSize: z.coerce.number().default(20).describe("Minimum symbol dimension in pixels"),
      maxSize: z.coerce.number().default(150).describe("Maximum symbol dimension in pixels"),
      limit: z.coerce.number().min(1).max(80).default(30).describe("Maximum candidates to return in JSON"),
      includeThumbnails: z.boolean().default(true).describe("Include image thumbnails for the top candidates"),
      thumbnailLimit: z.coerce.number().min(0).max(12).default(6).describe("Maximum thumbnails to emit"),
    },
    async ({ documentId, pageNumber, minSize, maxSize, limit, includeThumbnails, thumbnailLimit }) => {
      const result = await apiPost("/api/vision/find-symbols", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        minSize,
        maxSize,
      });

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Symbol candidate search failed: ${result.error ?? result.message ?? "unknown error"}` }] };
      }

      const candidates = asToolArray(result.candidates).slice(0, limit).map((candidate, index) => ({
        id: candidate.id ?? `sym-${index + 1}`,
        x: candidate.x,
        y: candidate.y,
        w: candidate.w,
        h: candidate.h,
        cx: candidate.cx,
        cy: candidate.cy,
        area: candidate.area,
        aspect: candidate.aspect,
        confidence: candidate.confidence ?? 0.52,
        source: candidate.source ?? "connected-component",
        bbox: { x: candidate.x, y: candidate.y, width: candidate.w, height: candidate.h },
        thumbnailIndex: includeThumbnails && index < thumbnailLimit ? index + 1 : undefined,
      }));

      const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [];
      if (includeThumbnails && thumbnailLimit > 0) {
        for (const candidate of candidates.slice(0, thumbnailLimit)) {
          const region = paddedRegion({
            x: Number(candidate.x),
            y: Number(candidate.y),
            width: Number(candidate.w),
            height: Number(candidate.h),
            imageWidth: Number(result.imageWidth),
            imageHeight: Number(result.imageHeight),
          }, 0.35);
          const crop = await apiPost("/api/vision/render-page", {
            projectId: getProjectId(),
            documentId,
            pageNumber,
            dpi: 300,
            region,
          }).catch(() => null);
          const base64 = imageBase64(crop?.image);
          if (base64) {
            content.push({ type: "image" as const, data: base64, mimeType: "image/png" as const });
          }
        }
      }

      content.push({
        type: "text" as const,
        text: JSON.stringify({
          documentId,
          pageNumber,
          imageWidth: result.imageWidth,
          imageHeight: result.imageHeight,
          total: result.total,
          returned: candidates.length,
          candidates,
          note: "Use candidate.bbox as a starting point for countDrawingSymbol, but tighten the box after visual review if needed.",
        }, null, 2),
      });

      return { content };
    }
  );

  // ── scanDrawingSymbols ────────────────────────────────────
  server.tool(
    "scanDrawingSymbols",
    `Scan a drawing page to discover repeating symbol candidates automatically. Returns a compact structured inventory of candidate clusters with counts and locations.

WHEN TO USE: Use this only after renderDrawingPage/zoomDrawingRegion has shown that a specific drawing sheet is symbol-heavy and the quantity question depends on small repeated symbols. Do NOT use it as a general page overview, and do NOT run it once on a random drawing just to satisfy a workflow checkbox. For precise counts, prefer countSymbols with a tight representative bounding box around one clean symbol.

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: Page to scan (1-based, default 1)
- maxClusters: Limit returned clusters. Default 20 keeps responses small.
- includeImage: Default false. Avoid true unless a human-visible image is essential; prefer renderDrawingPage + zoomDrawingRegion for visual inspection.

OUTPUT: A structured inventory with:
- clusters: Array of symbol types found, each containing:
  - id: Cluster identifier
  - sizeCategory: "small", "medium", or "large"
  - avgDimensions: Average width and height of symbols in this cluster
  - matchCount: How many instances of this symbol were found on the page
  - avgConfidence: Average template matching confidence (0-1)
  - representativeBox: Bounding box of the best example (use as bbox for countSymbols if you need to refine)
  - topMatches: Up to 3 match locations with coordinates and confidence
- imageWidth, imageHeight: The coordinate space (150 DPI) — use these if you call countSymbols to refine
- totalClusters: Number of distinct symbol types found
- totalSymbolsFound: Total symbol instances across all clusters

WORKFLOW:
1. Choose a relevant sheet/page based on the scope question.
2. Call renderDrawingPage and targeted zoomDrawingRegion first so you understand the visual context and the symbol/region you are trying to count.
3. Use this tool only as optional discovery on the already-relevant sheet; interpret clusters cautiously.
4. If a cluster matches the specific symbol, refine with countSymbols using the representativeBox or a tighter box from renderDrawingPage coordinates.
5. Do not treat this tool as proof of visual takeoff by itself; visual takeoff proof comes from rendered-page inspection plus targeted zooms.

Avg scan time 2-5 seconds per page.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number to scan (1-based)"),
      maxClusters: z.coerce.number().min(1).max(100).default(20).describe("Maximum number of clusters to return; keeps tool output compact"),
      includeImage: z.boolean().default(false).describe("Include the rendered page image. Defaults false to avoid huge base64/image payloads."),
    },
    async ({ documentId, pageNumber, maxClusters, includeImage }) => {
      const scanResult = await apiPost("/api/vision/scan-drawing", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
      });

      const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [];

      if (includeImage) {
        const renderResult = await apiPost("/api/vision/render-page", {
          projectId: getProjectId(),
          documentId,
          pageNumber,
          dpi: 150,
        });

        if (renderResult.success && renderResult.image) {
          const base64Match = (renderResult.image as string).match(/^data:image\/png;base64,(.+)$/);
          if (base64Match) {
            content.push({
              type: "image" as const,
              data: base64Match[1],
              mimeType: "image/png" as const,
            });
          }
        }
      }

      if (!scanResult.success) {
        content.push({
          type: "text" as const,
          text: `Scan failed: ${scanResult.error ?? JSON.stringify(scanResult)}`,
        });
        return { content };
      }

      // Format cluster data (strip thumbnails to save context, keep structure)
      const clusters = (scanResult.clusters ?? []).map((c: any) => ({
        id: c.id,
        sizeCategory: c.sizeCategory,
        avgDimensions: c.avgDimensions,
        matchCount: c.matchCount,
        avgConfidence: c.avgConfidence,
        representativeBox: {
          ...c.representativeBox,
          imageWidth: scanResult.imageWidth,
          imageHeight: scanResult.imageHeight,
        },
        topMatches: (c.topMatches ?? []).slice(0, 3),
      })).slice(0, maxClusters);
      const omittedClusters = Math.max(0, Number(scanResult.totalClusters ?? 0) - clusters.length);

      content.push({
        type: "text" as const,
        text: JSON.stringify({
          totalClusters: scanResult.totalClusters,
          totalSymbolsFound: scanResult.totalSymbolsFound,
          returnedClusters: clusters.length,
          omittedClusters,
          imageWidth: scanResult.imageWidth,
          imageHeight: scanResult.imageHeight,
          scanDuration_ms: scanResult.scanDuration_ms,
          documentId,
          pageNumber,
          clusters,
          note: includeImage
            ? "Each cluster represents a distinct symbol type. Use representativeBox with countSymbols if you need to refine the count."
            : "Compact response: no page image returned. Use renderDrawingPage plus zoomDrawingRegion for visual inspection. Use representativeBox with countSymbols only if a cluster matches the specific symbol you need to count.",
        }, null, 2),
      });

      return { content };
    }
  );

  // ── analyzeDrawingGeometry ────────────────────────────────
  server.tool(
    "analyzeDrawingGeometry",
    `Run Bidwright's generic OpenCV drawing-intelligence pass on a PDF page.

WHEN TO USE: Use this when linework, circles, candidate symbols, text regions, or connected drawing topology matter. This is the broad CV pass; it is not trade-specific unless you select a preset.

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: Page to analyze
- preset: generic by default; use mechanical_piping/plumbing/fire_protection/ductwork/electrical/civil_linear/structural to tune line filtering and system labels
- traceSystems: true to group connected linework into candidate runs/systems
- maxLines/maxRegions: optional output budgets; set maxLines to 0 for full line output on dense sheets

OUTPUT: Compact JSON with summary, coordinate space, top line segments, circles, symbol candidates, text regions, and systems. Coordinates are in the returned imageWidth/imageHeight coordinate space and can be saved as takeoff marks after human/agent review.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number to analyze"),
      geometrySource: z.enum(["auto", "pdf_raster", "cad_native"]).default("auto").describe("Use pdf_raster for PDF/image OpenCV, cad_native for DXF/DWG entity analysis, or auto to try PDF then CAD"),
      sourceKind: z.enum(["source_document", "file_node"]).default("source_document").describe("Source kind for CAD-native DXF/DWG analysis"),
      refreshCadMetadata: z.boolean().default(false).describe("Refresh cached DXF/DWG metadata before CAD-native analysis"),
      preset: drawingAnalysisPresetSchema.default("generic").describe("Trade/use-case preset for the generic CV engine"),
      traceSystems: z.boolean().default(true).describe("Group connected linework into candidate systems/runs"),
      includeSymbols: z.boolean().default(true).describe("Include symbol-like connected-component candidates"),
      includeTextRegions: z.boolean().default(true).describe("Include dense text-region candidates"),
      includeCircles: z.boolean().default(true).describe("Include circular detections"),
      maxLines: z.coerce.number().min(0).max(50000).default(0).describe("Maximum line segments to return; 0 returns the full detected line set"),
      maxEntities: z.coerce.number().min(0).max(50000).default(0).describe("Maximum CAD-native entities to return when geometrySource is cad_native; 0 returns all parsed entities"),
      maxRegions: z.coerce.number().min(20).max(2000).default(500).describe("Maximum candidates per non-line category"),
      minLineLength: z.coerce.number().min(5).optional().describe("Optional minimum detected line length in pixels"),
      snapTolerance: z.coerce.number().min(2).optional().describe("Optional endpoint snap tolerance in pixels for topology"),
      lineSensitivity: z.coerce.number().min(0.1).max(1).default(0.62).describe("Line detection sensitivity; higher finds finer/shorter linework"),
      noiseRejection: z.coerce.number().min(0).max(1).default(0.42).describe("Noise rejection strength; higher suppresses short noisy fragments"),
    },
    async (args) => {
      const {
        geometrySource,
        sourceKind,
        refreshCadMetadata,
        maxEntities,
        ...analysisArgs
      } = args;
      const projectId = getProjectId();
      const runPdf = () => apiPost("/api/vision/analyze-geometry", {
        projectId,
        ...analysisArgs,
      });
      const runCad = () => apiPost("/api/vision/analyze-cad-geometry", {
        projectId,
        ...analysisArgs,
        sourceKind,
        refresh: refreshCadMetadata,
        maxEntities,
      });

      let result: any;
      if (geometrySource === "cad_native") {
        result = await runCad();
      } else if (geometrySource === "pdf_raster") {
        result = await runPdf();
      } else {
        try {
          result = await runPdf();
        } catch {
          result = await runCad();
        }
      }

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Geometry analysis failed: ${result.error ?? result.message ?? JSON.stringify(result)}` }] };
      }

      const compact = {
        documentId: analysisArgs.documentId,
        pageNumber: result.pageNumber ?? analysisArgs.pageNumber,
        analysisId: result.analysisId,
        preset: result.preset,
        geometrySource: result.geometrySource ?? "pdf_raster",
        coordinateSpace: result.coordinateSpace ?? "pdf-render-pixels",
        units: result.units,
        imageWidth: result.imageWidth,
        imageHeight: result.imageHeight,
        scaleMetadata: result.scaleMetadata,
        preprocessing: result.preprocessing,
        summary: result.summary,
        warnings: result.warnings,
        layers: (result.layers ?? []).slice(0, 80),
        lines: (result.lines ?? []).slice(0, 80),
        polylines: (result.polylines ?? []).slice(0, 40),
        systems: (result.systems ?? []).slice(0, 40),
        circles: (result.circles ?? []).slice(0, 40),
        contours: (result.contours ?? []).slice(0, 40),
        symbolCandidates: (result.symbolCandidates ?? []).slice(0, 40),
        textRegions: (result.textRegions ?? []).slice(0, 40),
        omitted: {
          layers: Math.max(0, Number(result.layers?.length ?? 0) - 80),
          lines: Math.max(0, Number(result.lines?.length ?? 0) - 80),
          polylines: Math.max(0, Number(result.polylines?.length ?? 0) - 40),
          systems: Math.max(0, Number(result.systems?.length ?? 0) - 40),
          circles: Math.max(0, Number(result.circles?.length ?? 0) - 40),
          contours: Math.max(0, Number(result.contours?.length ?? 0) - 40),
          symbolCandidates: Math.max(0, Number(result.symbolCandidates?.length ?? 0) - 40),
          textRegions: Math.max(0, Number(result.textRegions?.length ?? 0) - 40),
        },
        note: "Review detections visually before saving. Use saveDetectionsAsTakeoffMarks only for accepted detections.",
      };

      return {
        content: [{ type: "text" as const, text: JSON.stringify(compact, null, 2) }],
      };
    }
  );

  // ── traceDrawingSystems ───────────────────────────────────
  server.tool(
    "traceDrawingSystems",
    `Trace connected linear systems/runs from drawing linework.

WHEN TO USE: Use this for pipe runs, conduit/cable tray paths, ducts, civil utilities, structural member runs, or any connected linear takeoff. This is intentionally generic; choose the preset that best matches the trade, then review the overlay/coordinates before saving.

OUTPUT: Candidate systems with segment IDs, total length in pixels, inferred topology counts (open ends, elbows, tees, crosses), confidence, and warnings.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number to trace"),
      geometrySource: z.enum(["auto", "pdf_raster", "cad_native"]).default("auto").describe("Use cad_native for DXF/DWG vector entity tracing"),
      sourceKind: z.enum(["source_document", "file_node"]).default("source_document").describe("Source kind for CAD-native DXF/DWG tracing"),
      refreshCadMetadata: z.boolean().default(false).describe("Refresh cached DXF/DWG metadata before CAD-native tracing"),
      preset: drawingAnalysisPresetSchema.default("mechanical_piping").describe("System-tracing preset"),
      maxLines: z.coerce.number().min(0).max(50000).default(0).describe("Maximum line segments to consider; 0 considers all detected linework"),
      maxEntities: z.coerce.number().min(0).max(50000).default(0).describe("Maximum CAD-native entities to consider; 0 considers all parsed entities"),
      minLineLength: z.coerce.number().min(5).optional().describe("Optional minimum line length in pixels"),
      snapTolerance: z.coerce.number().min(2).optional().describe("Optional endpoint snap tolerance in pixels"),
      lineSensitivity: z.coerce.number().min(0.1).max(1).default(0.62).describe("Line detection sensitivity; higher finds finer/shorter linework"),
      noiseRejection: z.coerce.number().min(0).max(1).default(0.42).describe("Noise rejection strength; higher suppresses short noisy fragments"),
    },
    async (args) => {
      const {
        geometrySource,
        sourceKind,
        refreshCadMetadata,
        maxEntities,
        ...traceArgs
      } = args;
      const projectId = getProjectId();
      const runPdf = () => apiPost("/api/vision/trace-systems", {
        projectId,
        ...traceArgs,
      });
      const runCad = () => apiPost("/api/vision/analyze-cad-geometry", {
        projectId,
        ...traceArgs,
        traceSystems: true,
        sourceKind,
        refresh: refreshCadMetadata,
        maxEntities,
      });
      let result: any;
      if (geometrySource === "cad_native") {
        result = await runCad();
      } else if (geometrySource === "pdf_raster") {
        result = await runPdf();
      } else {
        try {
          result = await runPdf();
        } catch {
          result = await runCad();
        }
      }

      if (!result.success) {
        return { content: [{ type: "text" as const, text: `Trace failed: ${result.error ?? result.message ?? JSON.stringify(result)}` }] };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            documentId: traceArgs.documentId,
            pageNumber: result.pageNumber ?? traceArgs.pageNumber,
            analysisId: result.analysisId,
            preset: result.preset,
            geometrySource: result.geometrySource ?? "pdf_raster",
            coordinateSpace: result.coordinateSpace ?? "pdf-render-pixels",
            units: result.units,
            imageWidth: result.imageWidth,
            imageHeight: result.imageHeight,
            scaleMetadata: result.scaleMetadata,
            preprocessing: result.preprocessing,
            summary: result.summary,
            warnings: result.warnings,
            layers: (result.layers ?? []).slice(0, 80),
            systems: (result.systems ?? []).slice(0, 60),
            polylines: (result.polylines ?? []).slice(0, 60),
            lines: (result.lines ?? []).slice(0, 120),
            note: "System lengths are pixel lengths until calibrated or reviewed. Save accepted systems/segments as takeoff marks before linking them to estimate rows.",
          }, null, 2),
        }],
      };
    }
  );

  // ── saveDetectionsAsTakeoffMarks ──────────────────────────
  server.tool(
    "saveDetectionsAsTakeoffMarks",
    `Persist reviewed drawing-intelligence detections as normal Bidwright takeoff marks.

WHEN TO USE: After analyzeDrawingGeometry or traceDrawingSystems returns detections and you have reviewed/selected the ones to keep. This creates Pickup rows so the user can see, edit, group, and link them to worksheet items.

INPUT DETECTION SHAPES:
- Linear detection: {id, kind:"line", label, points:[{x,y},{x,y}], measurement?}
- Count detection: {id, kind:"symbol"|"circle"|"device", label, points:[{x,y}], measurement:{value:1,unit:"count"}}
- System segment: save each accepted segment as a linear detection under a shared groupName

COMMON PITFALLS: Do not save every raw detection automatically. Save accepted detections with descriptive labels and groups.`,
    {
      documentId: z.string().describe("Document ID where detections were found"),
      pageNumber: z.coerce.number().min(1).describe("Page number where detections were found"),
      imageWidth: z.coerce.number().positive().describe("Image coordinate-space width returned by analyzeDrawingGeometry"),
      imageHeight: z.coerce.number().positive().describe("Image coordinate-space height returned by analyzeDrawingGeometry"),
      analysisId: z.string().optional().describe("Optional analysisId returned by analyzeDrawingGeometry/traceDrawingSystems, used to update accepted counts"),
      groupName: z.string().default("Drawing Intelligence").describe("Group name for saved annotations"),
      color: z.string().default("#0ea5e9").describe("Default annotation color"),
      detections: z.array(z.object({}).passthrough()).describe("Reviewed detections to persist as takeoff marks"),
    },
    async ({ documentId, pageNumber, imageWidth, imageHeight, analysisId, groupName, color, detections }) => {
      const result = await apiPost("/api/vision/save-detections-as-annotations", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        imageWidth,
        imageHeight,
        analysisId,
        groupName,
        color,
        detections,
      });

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            savedCount: result.savedCount ?? 0,
            requestedCount: detections.length,
            groupName,
            documentId,
            pageNumber,
            errors: result.errors?.length ? result.errors : undefined,
          }, null, 2),
        }],
      };
    }
  );

  // ── classifyDrawingLayers ────────────────────────────────
  server.tool(
    "classifyDrawingLayers",
    `Classify PDF optional-content layers or CAD layers/entities by likely construction discipline.

WHEN TO USE: Before running a trade-specific trace or count on CAD/vector drawings. This tells the agent whether the file exposes useful native layers and which layers look like piping, ductwork, electrical, structure, annotations, title blocks, or reference geometry.`,
    {
      documentId: z.string().describe("Document ID from listDrawingPages or a CAD/file-node document ID"),
      documentKind: z.enum(["auto", "pdf", "dwg_dxf"]).default("auto").describe("Native structure source to inspect"),
      pageNumber: z.coerce.number().min(1).optional().describe("Optional PDF page to sample"),
      maxPages: z.coerce.number().min(1).max(25).default(5).describe("PDF max page sample when pageNumber is omitted"),
      refresh: z.boolean().default(false).describe("Refresh CAD metadata when inspecting DWG/DXF"),
      sourceKind: z.enum(["source_document", "file_node"]).default("source_document").describe("Source kind for CAD/DXF metadata"),
    },
    async ({ documentId, documentKind, pageNumber, maxPages, refresh, sourceKind }) => {
      const projectId = getProjectId();
      const attempts: Record<string, any> = {};
      let pdfNative: any = null;
      let cadNative: any = null;

      if (documentKind !== "dwg_dxf") {
        try {
          pdfNative = await apiPost("/api/vision/pdf-native-summary", {
            projectId,
            documentId,
            pageNumber,
            maxPages,
          });
        } catch (error) {
          attempts.pdf = error instanceof Error ? error.message : String(error);
        }
      }

      if (documentKind !== "pdf" && (!pdfNative?.hasOptionalContentLayers || documentKind === "dwg_dxf")) {
        const query = new URLSearchParams({
          refresh: refresh ? "1" : "0",
          sourceKind,
        });
        try {
          cadNative = await apiGet(`/api/takeoff/${projectId}/documents/${encodeURIComponent(documentId)}/dwg-metadata?${query.toString()}`);
        } catch (error) {
          attempts.dwgDxf = error instanceof Error ? error.message : String(error);
        }
      }

      const pdfLayers = asToolArray(pdfNative?.layers).map((layer) => ({
        id: layer.id,
        name: layer.name,
        nativeClassification: layer.classification,
        discipline: layerDiscipline(layer.name || layer.classification),
        intent: layer.intent ?? null,
        usage: layer.usage ?? null,
      }));

      const cadLayersRaw = asToolArray(cadNative?.layers ?? cadNative?.result?.layers ?? cadNative?.metadata?.layers);
      const cadLayers = cadLayersRaw.map((layer, index) => {
        const name = layer.name ?? layer.layer ?? layer.id ?? `layer-${index + 1}`;
        return {
          id: layer.id ?? String(name),
          name,
          discipline: layerDiscipline(name),
          entityCount: layer.entityCount ?? layer.count ?? layer.entities ?? null,
          color: layer.color ?? null,
          lineType: layer.lineType ?? layer.linetype ?? null,
          raw: layer,
        };
      });

      const source = cadNative ? "dwg_dxf" : "pdf";
      const layers = cadNative ? cadLayers : pdfLayers;
      const disciplineCounts = layers.reduce<Record<string, number>>((acc, layer) => {
        const key = String(layer.discipline ?? "unknown");
        acc[key] = (acc[key] ?? 0) + 1;
        return acc;
      }, {});

      const pages = asToolArray(pdfNative?.pages).map((page) => ({
        pageNumber: page.pageNumber,
        width: page.width,
        height: page.height,
        textItemCount: page.textItemCount,
        operatorCount: page.operatorCount,
        vectorSignals: page.vectorSignals,
      }));

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            success: Boolean(pdfNative?.success || cadNative),
            documentId,
            source,
            fileName: pdfNative?.fileName ?? cadNative?.fileName ?? cadNative?.source?.fileName,
            layerCount: layers.length,
            disciplineCounts,
            layers: layers.slice(0, 160),
            omittedLayers: Math.max(0, layers.length - 160),
            pdf: pdfNative ? {
              pageCount: pdfNative.pageCount,
              hasOptionalContentLayers: pdfNative.hasOptionalContentLayers,
              layerClassCounts: pdfNative.layerClassCounts,
              pages,
            } : null,
            cad: cadNative ? {
              status: cadNative.status ?? cadNative.result?.status,
              entityCount: cadNative.entityCount ?? cadNative.result?.entityCount,
              entityTypeCounts: cadNative.entityTypeCounts ?? cadNative.result?.entityTypeCounts,
              layoutCount: asToolArray(cadNative.layouts ?? cadNative.result?.layouts).length || undefined,
            } : null,
            attempts: Object.keys(attempts).length ? attempts : undefined,
            note: "Use disciplineCounts to pick a tracing preset. Unknown or annotation-heavy layers usually need pixel CV plus visual review rather than blind save.",
          }, null, 2),
        }],
      };
    }
  );

  // ── inspectDetectionRegion ───────────────────────────────
  server.tool(
    "inspectDetectionRegion",
    `Return a high-resolution crop of a detection region plus nearby native text and stored analysis detections.

WHEN TO USE: After analyzeDrawingGeometry, traceDrawingSystems, findDrawingSymbolCandidates, or compareAnalysisToTakeoff returns a candidate and the agent needs visual evidence before saving, rejecting, or linking it.`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number"),
      region: z.object(boundingBoxSchema).describe("Region to inspect in render/analyze image coordinates"),
      paddingRatio: z.coerce.number().min(0).max(2).default(0.3).describe("Padding around the region before rendering the crop"),
      analysisId: z.string().optional().describe("Optional stored analysis ID to include intersecting detections"),
      includeNativeContext: z.boolean().default(true).describe("Include nearby PDF-native text/layer context when available"),
      includeImage: z.boolean().default(true).describe("Include the crop image in the tool result"),
    },
    async ({ documentId, pageNumber, region, paddingRatio, analysisId, includeNativeContext, includeImage }) => {
      const projectId = getProjectId();
      const padded = paddedRegion(region, paddingRatio);
      const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: "image/png" }> = [];

      let crop: any = null;
      if (includeImage) {
        crop = await apiPost("/api/vision/render-page", {
          projectId,
          documentId,
          pageNumber,
          dpi: 300,
          region: padded,
        }).catch((error) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
        const base64 = imageBase64(crop?.image);
        if (base64) content.push({ type: "image" as const, data: base64, mimeType: "image/png" as const });
      }

      let nativeContext: any = null;
      if (includeNativeContext) {
        const native = await apiPost("/api/vision/pdf-native-summary", {
          projectId,
          documentId,
          pageNumber,
          maxPages: 1,
        }).catch((error) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
        const page = asToolArray(native?.pages)[0];
        const pageWidth = Number(page?.width ?? 0);
        const pageHeight = Number(page?.height ?? 0);
        const sx = pageWidth > 0 ? region.imageWidth / pageWidth : 1;
        const sy = pageHeight > 0 ? region.imageHeight / pageHeight : 1;
        const nearbyText = asToolArray(page?.textItemsSample)
          .map((item) => {
            const x = Number(item.x);
            const y = Number(item.y);
            const width = Number(item.width ?? 0);
            const height = Number(item.height ?? 8);
            if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
            const box = {
              x: x * sx,
              y: region.imageHeight - (y * sy) - Math.max(1, height * sy),
              width: Math.max(1, width * sx),
              height: Math.max(1, height * sy),
            };
            return { text: item.text, bbox: box, fontName: item.fontName ?? null };
          })
          .filter((item): item is { text: any; bbox: { x: number; y: number; width: number; height: number }; fontName: any } => Boolean(item))
          .filter((item) => bboxIntersects(item.bbox, padded))
          .slice(0, 30);
        nativeContext = {
          success: native?.success === true,
          error: native?.error,
          layers: asToolArray(native?.layers).slice(0, 80),
          page: page ? {
            pageNumber: page.pageNumber,
            width: page.width,
            height: page.height,
            textItemCount: page.textItemCount,
            vectorSignals: page.vectorSignals,
          } : null,
          nearbyText,
        };
      }

      let analysisContext: any = null;
      if (analysisId) {
        const analyses = await apiPost("/api/vision/list-analyses", {
          projectId,
          documentId,
          pageNumber,
          includeDetections: true,
        }).catch((error) => ({ success: false, error: error instanceof Error ? error.message : String(error), analyses: [] }));
        const run = asToolArray(analyses?.analyses).find((entry) => String(entry.id) === analysisId);
        const detections = asToolRecord(run?.detections);
        const allDetections = [
          ...asToolArray(detections.lines),
          ...asToolArray(detections.polylines),
          ...asToolArray(detections.circles),
          ...asToolArray(detections.contours),
          ...asToolArray(detections.symbolCandidates),
          ...asToolArray(detections.textRegions),
          ...asToolArray(detections.systems),
        ];
        analysisContext = {
          found: Boolean(run),
          analysisId,
          summary: run?.summary,
          intersectingDetections: allDetections
            .map((detection) => ({ detection, bbox: bboxFromAny(detection) }))
            .filter((entry) => entry.bbox && bboxIntersects(entry.bbox, padded))
            .slice(0, 80)
            .map((entry) => entry.detection),
        };
      }

      content.push({
        type: "text" as const,
        text: JSON.stringify({
          success: crop?.success !== false,
          documentId,
          pageNumber,
          requestedRegion: region,
          renderedRegion: padded,
          crop: crop ? {
            width: crop.width,
            height: crop.height,
            dpi: 300,
            error: crop.error,
          } : null,
          nativeContext,
          analysisContext,
          note: "Use the crop as visual evidence. Coordinates in nativeContext/analysisContext are mapped back to the original image coordinate space.",
        }, null, 2),
      });

      return { content };
    }
  );

  // ── listDrawingAnalyses ──────────────────────────────────
  server.tool(
    "listDrawingAnalyses",
    `List previous Drawing Intelligence analysis runs stored on a drawing document.

WHEN TO USE: Use this to avoid rerunning expensive CV work, audit accepted/rejected counts, or find the analysisId required by compareAnalysisToTakeoff and inspectDetectionRegion.`,
    {
      documentId: z.string().describe("Document ID of the analyzed drawing"),
      pageNumber: z.coerce.number().min(1).optional().describe("Optional page filter"),
      includeDetections: z.boolean().default(false).describe("Include compact detection refs stored with each run"),
    },
    async ({ documentId, pageNumber, includeDetections }) => {
      const result = await apiPost("/api/vision/list-analyses", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        includeDetections,
      });

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            success: result.success,
            documentId: result.documentId ?? documentId,
            fileName: result.fileName,
            count: result.count,
            analyses: asToolArray(result.analyses).map((run) => includeDetections ? run : {
              id: run.id,
              status: run.status,
              tool: run.tool,
              createdAt: run.createdAt,
              pageNumber: run.pageNumber,
              preset: run.preset,
              parameters: run.parameters,
              imageWidth: run.imageWidth,
              imageHeight: run.imageHeight,
              summary: run.summary,
              warnings: run.warnings,
              acceptedCount: run.acceptedCount,
              rejectedCount: run.rejectedCount,
              savedAnnotationIds: run.savedAnnotationIds,
            }),
          }, null, 2),
        }],
      };
    }
  );

  // ── compareAnalysisToTakeoff ─────────────────────────────
  server.tool(
    "compareAnalysisToTakeoff",
    `Compare a stored drawing analysis with saved takeoff annotations and worksheet links.

WHEN TO USE: After CV has found candidates, use this to tell what is detected but not yet saved, and what is saved as a mark but not yet linked to an estimate/takeoff row.`,
    {
      documentId: z.string().describe("Document ID of the analyzed drawing"),
      analysisId: z.string().optional().describe("Specific analysis run ID. If omitted, the newest matching page run is used."),
      pageNumber: z.coerce.number().min(1).optional().describe("Optional page filter when analysisId is omitted"),
      limit: z.coerce.number().min(1).max(500).default(120).describe("Maximum unsaved/unlinked entries to return"),
    },
    async ({ documentId, analysisId, pageNumber, limit }) => {
      const result = await apiPost("/api/vision/compare-analysis-to-takeoff", {
        projectId: getProjectId(),
        documentId,
        analysisId,
        pageNumber,
        limit,
      });

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify(result, null, 2),
        }],
      };
    }
  );

  // ── detectScale ────────────────────────────────────────────
  server.tool(
    "detectScale",
    `Render a drawing page and extract the title block area to detect the drawing scale.

WHEN TO USE: Before measuring distances on a drawing with measureLinear. The title block (usually bottom-right corner) contains scale information like "1/4" = 1'-0"", "1:50", or "SCALE: 1"=20'". This tool renders that area at high resolution so you can read the scale notation.

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: Page number (default 1). For multi-page drawing sets, each page may have a different scale

OUTPUT: A high-resolution image of the title block region plus metadata (page dimensions, image dimensions). YOU must visually read the scale from the image and calculate the pixelsPerUnit calibration for measureLinear.

HOW TO CALCULATE CALIBRATION: If the scale is "1/4" = 1'-0"" and the image was rendered at 150 DPI:
- 1/4" on paper = 150 * 0.25 = 37.5 pixels
- 1/4" represents 1 foot
- So pixelsPerUnit = 37.5 pixels per foot

COMMON PITFALLS:
- Title blocks are usually in the bottom-right corner, but not always — if the tool doesn't find scale info, try renderDrawingPage and look manually
- Some drawings have multiple scales (e.g. "PLAN: 1/4" = 1'-0"", "DETAIL: 1" = 1'-0"") — use the one relevant to your measurement area
- The scale may be for the original print size (e.g. "24x36") — the DPI factor matters`,
    {
      documentId: z.string().describe("Document ID of the PDF drawing"),
      pageNumber: z.coerce.number().min(1).default(1).describe("Page number (1-based)"),
    },
    async ({ documentId, pageNumber }) => {
      // Render the bottom-right quadrant at high DPI (title blocks are typically there)
      const fullRender = await apiPost("/api/vision/render-page", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        dpi: 100, // Low DPI first to get dimensions
      });

      if (!fullRender.success) {
        return { content: [{ type: "text" as const, text: `Failed: ${fullRender.error}` }] };
      }

      // Render the title block region (bottom-right 30% x 15%)
      const w = fullRender.width as number;
      const h = fullRender.height as number;
      const titleBlockRegion = {
        x: Math.round(w * 0.65),
        y: Math.round(h * 0.85),
        width: Math.round(w * 0.35),
        height: Math.round(h * 0.15),
        imageWidth: w,
        imageHeight: h,
      };

      const zoomResult = await apiPost("/api/vision/render-page", {
        projectId: getProjectId(),
        documentId,
        pageNumber,
        dpi: 300,
        region: titleBlockRegion,
      });

      if (!zoomResult.success || !zoomResult.image) {
        return { content: [{ type: "text" as const, text: `Failed to render title block: ${zoomResult.error}` }] };
      }

      const base64Match = (zoomResult.image as string).match(/^data:image\/png;base64,(.+)$/);
      if (!base64Match) {
        return { content: [{ type: "text" as const, text: "Image format error" }] };
      }

      return {
        content: [
          {
            type: "image" as const,
            data: base64Match[1],
            mimeType: "image/png" as const,
          },
          {
            type: "text" as const,
            text: JSON.stringify({
              region: "title block (bottom-right)",
              pageWidth: fullRender.pageWidth,
              pageHeight: fullRender.pageHeight,
              imageWidth: w,
              imageHeight: h,
              instruction: "Look at this title block image for scale information (e.g. '1/4\" = 1\\'-0\"', 'SCALE: 1:50'). Report the scale you find.",
            }, null, 2),
          },
        ],
      };
    }
  );

  // ── measureLinear ──────────────────────────────────────────
  server.tool(
    "measureLinear",
    `Measure a linear distance on a drawing between two points using calibration.

WHEN TO USE: After establishing calibration (via detectScale or a known dimension), use this to measure real-world distances between any two points on the drawing. Works for walls, pipes, cable runs, duct lengths, etc.

INPUTS:
- pointA, pointB: Start and end points in renderDrawingPage image coordinates (pixels)
- pixelsPerUnit: Calibration factor — how many pixels equal one real-world unit. Calculate this from the drawing scale (see detectScale)
- unit: The real-world unit (ft, in, m, cm, etc.)

OUTPUT: pixelDistance (raw pixel distance), realDistance (calibrated real-world distance), and the unit.

HOW TO USE:
1. Call detectScale to find the drawing scale
2. Calculate pixelsPerUnit from the scale and DPI
3. Call measureLinear with two points and the calibration

COMMON PITFALLS:
- Points must be in renderDrawingPage coordinate space
- Make sure pixelsPerUnit matches the DPI of the image you measured points from
- For angled measurements, the tool calculates the true hypotenuse distance, not just horizontal or vertical
- Double-check calibration by measuring a known dimension first (e.g. a labeled wall length)`,
    {
      pointA: z.object({ x: z.coerce.number(), y: z.coerce.number() }).describe("Start point in image coordinates"),
      pointB: z.object({ x: z.coerce.number(), y: z.coerce.number() }).describe("End point in image coordinates"),
      pixelsPerUnit: z.coerce.number().positive().describe("Calibration: how many pixels per real-world unit"),
      unit: z.string().default("ft").describe("Real-world unit (ft, in, m, etc.)"),
    },
    async ({ pointA, pointB, pixelsPerUnit, unit }) => {
      const pixelDist = Math.sqrt((pointB.x - pointA.x) ** 2 + (pointB.y - pointA.y) ** 2);
      const realDist = pixelDist / pixelsPerUnit;

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            pixelDistance: Math.round(pixelDist * 100) / 100,
            realDistance: Math.round(realDist * 1000) / 1000,
            unit,
            pointA,
            pointB,
          }, null, 2),
        }],
      };
    }
  );

  // ── listProjectSymbolLibrary ────────────────────────────────
  server.tool(
    "listProjectSymbolLibrary",
    `List every saved symbol template in the project's Symbol Library.

WHEN TO USE: Before running runProjectSymbolLibrary, call this to confirm the project has saved templates and inspect them. Useful when the user asks "what symbols can we auto-count?" or "show me the project legend".

OUTPUT: Per-template id, symbol token (e.g. "GFI"), label ("Standard duplex receptacle"), threshold, crossScale, enabled flag, sourceDocumentId/sourcePage (where the legend came from), and the lastRun summary when available.`,
    {
      enabledOnly: z.boolean().default(false).describe("If true, return only templates with enabled=true (those that would be included in a batch run)."),
    },
    async ({ enabledOnly }) => {
      const projectId = getProjectId();
      const qs = enabledOnly ? "?enabledOnly=1" : "";
      const result = await apiGet(`/api/takeoff/${projectId}/symbol-templates${qs}`);
      const templates = ((result as any)?.templates ?? []).map((t: any) => ({
        id: t.id,
        symbol: t.symbol,
        label: t.label,
        threshold: t.threshold,
        crossScale: t.crossScale,
        enabled: t.enabled,
        sourceDocumentId: t.sourceDocumentId,
        sourcePage: t.sourcePage,
        lastRun: t.metadata?.lastRun ?? null,
      }));
      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({ count: templates.length, templates }, null, 2),
        }],
      };
    },
  );

  // ── runProjectSymbolLibrary ─────────────────────────────────
  server.tool(
    "runProjectSymbolLibrary",
    `Run the project's Symbol Library against a drawing page or an entire document. Matches every enabled saved template in one batched pass (one PDF render, N OpenCV matches).

WHEN TO USE: After the user has captured a legend with extract-legend and saved templates via the Symbol Library panel, this counts every saved symbol on the chosen page(s) in a single call. This is the few-shot scale-up to the manual countSymbols flow — instead of running countSymbols once per symbol type, all saved templates run together.

INPUTS:
- documentId: Document ID from listDrawingPages
- pageNumber: 1-based page to scan, OR omit and set scope="document" to scan every page in the document
- scope: "page" (default) or "document"
- autoSave: If true, persist each match as a Pickup (green count marks). Default false — set to true when the user confirms results look right.
- templateIds: Optional array; restrict the run to specific saved templates. Omit to run every enabled template.

OUTPUT (scope=page):
{ documentId, pageNumber, imageWidth, imageHeight, dpi, duration_ms,
  templateResults: [{ templateId, symbol, label, totalCount, matches:[{x,y,w,h,confidence}], savedAnnotationIds }],
  errors }

OUTPUT (scope=document): one entry per page in the pages array plus a grandTotal across templates and pages.

TIPS:
- Run scope="page" first while the user is reviewing one drawing; switch to scope="document" only after they confirm the per-page results look right.
- A template's threshold lives on the template row — change it via the UI or by updating metadata; this tool does not override per-call.
- crossScale is also per-template. The library defaults to single-scale (faster); set crossScale=true on individual templates when symbols are rendered at varying sizes across the package.`,
    {
      documentId: z.string().describe("Document ID of the target PDF drawing"),
      pageNumber: z.coerce.number().int().min(1).optional().describe("1-based page number. Required when scope='page'."),
      scope: z.enum(["page", "document"]).default("page").describe("'page' = one page, 'document' = every page in the document"),
      autoSave: z.boolean().default(false).describe("Persist each match as a Pickup"),
      templateIds: z.array(z.string()).optional().describe("Restrict to specific saved template ids. Default: all enabled templates."),
    },
    async ({ documentId, pageNumber, scope, autoSave, templateIds }) => {
      const projectId = getProjectId();
      if (scope === "document") {
        const result = await apiPost(`/api/takeoff/${projectId}/symbol-templates/run-on-document`, {
          documentId,
          autoSave,
          templateIds,
        });
        if ((result as any)?.message) {
          return { content: [{ type: "text" as const, text: `Run failed: ${(result as any).message}` }] };
        }
        const pages = ((result as any)?.pages ?? []).map((p: any) => ({
          pageNumber: p.pageNumber,
          imageWidth: p.imageWidth,
          imageHeight: p.imageHeight,
          dpi: p.dpi,
          totalsBySymbol: (p.templateResults ?? []).map((tr: any) => ({
            templateId: tr.templateId,
            symbol: tr.symbol,
            label: tr.label,
            totalCount: tr.totalCount,
            savedAnnotationIds: tr.savedAnnotationIds ?? undefined,
            error: tr.error,
          })),
          errors: p.errors?.length ? p.errors : undefined,
        }));
        return {
          content: [{
            type: "text" as const,
            text: JSON.stringify({
              documentId,
              pageCount: (result as any)?.pageCount ?? pages.length,
              grandTotal: (result as any)?.grandTotal ?? 0,
              duration_ms: (result as any)?.duration_ms,
              pages,
            }, null, 2),
          }],
        };
      }

      if (!pageNumber) {
        return { content: [{ type: "text" as const, text: "pageNumber is required when scope='page'" }] };
      }
      const result = await apiPost(`/api/takeoff/${projectId}/symbol-templates/run-on-page`, {
        documentId,
        pageNumber,
        autoSave,
        templateIds,
      });
      if ((result as any)?.message) {
        return { content: [{ type: "text" as const, text: `Run failed: ${(result as any).message}` }] };
      }
      const summary = ((result as any)?.templateResults ?? []).map((tr: any) => ({
        templateId: tr.templateId,
        symbol: tr.symbol,
        label: tr.label,
        totalCount: tr.totalCount,
        // Trim match payloads for the agent — the agent rarely needs every
        // rect, just the totals + saved annotation ids when autoSave was on.
        sampleMatches: (tr.matches ?? []).slice(0, 3).map((m: any) => ({
          rect: { x: m.x, y: m.y, width: m.w, height: m.h },
          confidence: m.confidence,
        })),
        savedAnnotationIds: tr.savedAnnotationIds,
        error: tr.error,
      }));
      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            documentId,
            pageNumber,
            imageWidth: (result as any)?.imageWidth,
            imageHeight: (result as any)?.imageHeight,
            dpi: (result as any)?.dpi,
            duration_ms: (result as any)?.duration_ms,
            templateResults: summary,
            errors: (result as any)?.errors?.length ? (result as any).errors : undefined,
          }, null, 2),
        }],
      };
    },
  );
}
