import crypto from "crypto";
import fs from "fs";
import path from "path";
import { callText as defaultCallText } from "./llm-client.ts";
import { normalizeModelImageInput } from "./model-image-preprocess.ts";
import { modelSupportsImage } from "./message-sanitizer.ts";
import { getVisionCapabilities, modelSupportsDirectImageInput } from "../shared/model-capabilities.ts";

export const VISION_CONTEXT_START = "<vision-context>";
export const VISION_CONTEXT_END = "</vision-context>";
export const VISUAL_PRIMITIVES_START = "<visual-primitives";
export const VISUAL_PRIMITIVES_END = "</visual-primitives>";

const MAX_NOTE_CHARS = 3200;
const MAX_CACHE_ENTRIES = 256;
const MAX_VISUAL_PRIMITIVES = 16;
const MAX_PRIMITIVE_REF_CHARS = 96;
const VISION_ANALYSIS_TIMEOUT_MS = 120_000;
const VISION_NOTES_FILE = "session-vision-notes.json";
const DEFAULT_VISION_MAX_TOKENS = 4096;
const AUXILIARY_VISION_SYSTEM_PROMPT = [
  "You are Hana's auxiliary vision model.",
  "Analyze the supplied image for the target model and follow the requested output format exactly.",
  "Do not mention hidden reasoning, internal tools, or that another model will read your answer.",
].join("\n");

function normalizeUserRequest(text) {
  return String(text || "")
    .replace(/\[attached_image:\s*[^\]]+\]/g, "")
    .replace(/\s+/g, " ")
    .trim();
}

function imagePromptCacheKey(img, userRequest, modelSignature = "") {
  const h = crypto.createHash("sha256");
  h.update(img?.mimeType || "image/png");
  h.update("\0");
  h.update(img?.data || "");
  h.update("\0");
  h.update(userRequest || "");
  h.update("\0");
  h.update(modelSignature || "");
  return h.digest("hex");
}

function truncate(text, max = MAX_NOTE_CHARS) {
  const s = String(text || "").trim();
  return s.length > max ? `${s.slice(0, max - 20)}\n[truncated]` : s;
}

function positiveInteger(value) {
  const n = Number(value);
  return Number.isInteger(n) && n > 0 ? n : null;
}

function visionOutputLimit(model) {
  return positiveInteger(model?.maxTokens ?? model?.maxOutput ?? model?.maxOutputTokens);
}

function abortError() {
  const err = new Error("This operation was aborted");
  err.name = "AbortError";
  (err as any).type = "aborted";
  return err;
}

function throwIfAborted(signal) {
  if (signal?.aborted) throw abortError();
}

function requiresAuxiliaryVision(model) {
  return Array.isArray(model?.input) && !modelSupportsDirectImageInput(model);
}

function uniquePathsFromText(text) {
  const paths = [];
  const re = /\[attached_image:\s*([^\]]+)\]/g;
  let m;
  while ((m = re.exec(text || ""))) paths.push(m[1].trim());
  return paths;
}

function sessionNotesDir(sessionPath) {
  if (!sessionPath) return null;
  const dir = path.dirname(sessionPath);
  return path.basename(dir) === "archived" ? path.dirname(dir) : dir;
}

function sessionNotesPath(sessionPath) {
  const dir = sessionNotesDir(sessionPath);
  return dir ? path.join(dir, VISION_NOTES_FILE) : null;
}

function sessionNotesKey(sessionPath) {
  return sessionPath ? path.basename(sessionPath) : null;
}

function emptyNotesSidecar() {
  return { version: 1, sessions: {} };
}

function readNotesSidecar(filePath) {
  if (!filePath) return emptyNotesSidecar();
  try {
    const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
    if (!parsed || typeof parsed !== "object") return emptyNotesSidecar();
    if (!parsed.sessions || typeof parsed.sessions !== "object") {
      return { version: 1, sessions: {} };
    }
    return parsed;
  } catch (err) {
    if (err.code === "ENOENT") return emptyNotesSidecar();
    throw err;
  }
}

function writeNotesSidecar(filePath, data) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
  fs.renameSync(tmp, filePath);
}

function compactModelRef(model) {
  return model?.id && model?.provider
    ? { id: model.id, provider: model.provider }
    : null;
}

function contentText(content) {
  if (typeof content === "string") return content;
  if (!Array.isArray(content)) return "";
  return content
    .map((block) => block?.type === "text" ? block.text || "" : "")
    .join("\n");
}

function replaceTextContent(content, replacer) {
  if (typeof content === "string") return replacer(content);
  if (!Array.isArray(content)) return content;
  let changed = false;
  const next = content.map((block) => {
    if (block?.type !== "text") return block;
    const text = block.text || "";
    const replaced = replacer(text);
    if (replaced !== text) changed = true;
    return replaced !== text ? { ...block, text: replaced } : block;
  });
  return changed ? next : content;
}

function visionModelCacheSignature(model, visionCapabilities) {
  return JSON.stringify({
    provider: model?.provider || "",
    id: model?.id || "",
    visionCapabilities: visionCapabilities || null,
  });
}

function primitiveBoxOrderLabel(visionCapabilities) {
  return visionCapabilities?.boxOrder === "yxyx"
    ? "[ymin, xmin, ymax, xmax]"
    : "[x1, y1, x2, y2]";
}

function primitivePromptShape(visionCapabilities) {
  const format = visionCapabilities?.outputFormat || "hanako";
  if (format === "gemini") {
    return [
      '  "visual_primitives": [',
      '    {"id":"v1","type":"box","label":"short label","box_2d":[0,0,0,0],"confidence":0.0}',
      "  ]",
      "For Gemini-family models, use box_2d with the native [ymin, xmin, ymax, xmax] order normalized to 0-1000.",
    ];
  }
  if (format === "qwen") {
    return [
      '  "visual_primitives": [',
      '    {"id":"v1","label":"short label","bbox_2d":[0,0,0,0],"point_2d":[0,0],"confidence":0.0}',
      "  ]",
      "For Qwen-family models, use bbox_2d as [x1, y1, x2, y2] and point_2d as [x, y], normalized to 0-1000.",
    ];
  }
  if (format === "anchor") {
    return [
      '  "visual_anchors": [',
      '    {"id":"v1","label":"short label","role":"button|text|object|region","center":[0,0],"box":[0,0,0,0],"confidence":0.0}',
      "  ]",
      "For computer-use style models, prefer visual_anchors with center [x, y] for clickable or salient targets, plus box [x1, y1, x2, y2] when visible.",
    ];
  }
  return [
    '  "visual_primitives": [',
    '    {"id":"v1","type":"box","ref":"short label","box":[0,0,0,0],"confidence":0.0}',
    "  ]",
    `For boxes, output the box array as ${primitiveBoxOrderLabel(visionCapabilities)} normalized to 0-1000.`,
  ];
}

function safeSection(value, fallback = "none") {
  if (Array.isArray(value)) {
    const joined = value.map((item) => String(item || "").trim()).filter(Boolean).join("; ");
    return joined || fallback;
  }
  const s = String(value || "").trim();
  return s || fallback;
}

function clampNorm(value) {
  const n = Number(value);
  if (!Number.isFinite(n)) return null;
  return Math.max(0, Math.min(1000, Math.round(n)));
}

function normalizeBox(rawBox, visionCapabilities) {
  if (!Array.isArray(rawBox) || rawBox.length !== 4) return null;
  const coords = rawBox.map(clampNorm);
  if (coords.some((n) => n === null)) return null;

  let x1;
  let y1;
  let x2;
  let y2;
  if (visionCapabilities?.boxOrder === "yxyx") {
    [y1, x1, y2, x2] = coords;
  } else {
    [x1, y1, x2, y2] = coords;
  }

  const left = Math.min(x1, x2);
  const top = Math.min(y1, y2);
  const right = Math.max(x1, x2);
  const bottom = Math.max(y1, y2);
  if (left === right || top === bottom) return null;
  return [left, top, right, bottom];
}

function normalizePoint(rawPoint) {
  if (!Array.isArray(rawPoint) || rawPoint.length !== 2) return null;
  const point = rawPoint.map(clampNorm);
  if (point.some((n) => n === null)) return null;
  return point;
}

function primitiveLabel(raw, fallbackId) {
  const source = raw?.ref ?? raw?.label ?? raw?.text ?? raw?.name ?? raw?.id ?? fallbackId;
  const s = String(source || fallbackId).replace(/\s+/g, " ").trim();
  return s.slice(0, MAX_PRIMITIVE_REF_CHARS) || fallbackId;
}

function primitiveId(raw, index) {
  const candidate = String(raw?.id || `v${index + 1}`).trim().replace(/[^a-zA-Z0-9_-]/g, "_");
  return candidate || `v${index + 1}`;
}

function normalizePrimitive(raw, index, visionCapabilities) {
  if (!raw || typeof raw !== "object") return null;
  const rawBox = raw.box ?? raw.bbox ?? raw.bbox_2d ?? raw.box_2d;
  const rawPoint = raw.point ?? raw.point_2d ?? raw.center;

  const box = visionCapabilities?.boxes ? normalizeBox(rawBox, visionCapabilities) : null;
  if (box) {
    return {
      id: primitiveId(raw, index),
      type: "box",
      ref: primitiveLabel(raw, `v${index + 1}`),
      box,
      confidence: normalizeConfidence(raw.confidence),
      grounding: visionCapabilities?.groundingMode || "native",
    };
  }

  const point = visionCapabilities?.points ? normalizePoint(rawPoint) : null;
  if (point) {
    return {
      id: primitiveId(raw, index),
      type: "point",
      ref: primitiveLabel(raw, `v${index + 1}`),
      point,
      confidence: normalizeConfidence(raw.confidence),
      grounding: visionCapabilities?.groundingMode || "native",
    };
  }

  return null;
}

function normalizeConfidence(value) {
  if (value === undefined || value === null || value === "") return null;
  const n = Number(value);
  if (!Number.isFinite(n)) return null;
  return Math.max(0, Math.min(1, n));
}

function normalizeVisualPrimitives(items, visionCapabilities) {
  if (!Array.isArray(items)) return [];
  const normalized = [];
  for (let i = 0; i < items.length && normalized.length < MAX_VISUAL_PRIMITIVES; i++) {
    const primitive = normalizePrimitive(items[i], i, visionCapabilities);
    if (primitive) normalized.push(primitive);
  }
  return normalized;
}

function rawVisualPrimitiveItems(analysis) {
  if (Array.isArray(analysis?.visual_primitives)) return analysis.visual_primitives;
  if (Array.isArray(analysis?.visual_anchors)) return analysis.visual_anchors;
  if (Array.isArray(analysis?.anchors)) return analysis.anchors;
  return [];
}

function extractJsonObject(text) {
  const raw = String(text || "").trim();
  if (!raw) return null;

  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
  const primary = fenced ? fenced[1].trim() : raw;
  try {
    return JSON.parse(primary);
  } catch {
    const start = primary.indexOf("{");
    const end = primary.lastIndexOf("}");
    if (start === -1 || end <= start) return null;
    try {
      return JSON.parse(primary.slice(start, end + 1));
    } catch {
      return null;
    }
  }
}

function formatVisualPrimitives(primitives, groundingMode = "unavailable") {
  if (!primitives.length) {
    return [
      `${VISUAL_PRIMITIVES_START} coord="norm-1000" box_order="xyxy" grounding="unavailable">`,
      "- unavailable | reason: no valid coordinates",
      VISUAL_PRIMITIVES_END,
    ].join("\n");
  }
  const lines = primitives.map((primitive) => {
    const coord = primitive.type === "box"
      ? `box: [${primitive.box.join(", ")}]`
      : `point: [${primitive.point.join(", ")}]`;
    const confidence = primitive.confidence === null ? "" : ` | confidence: ${primitive.confidence.toFixed(2)}`;
    return `- ${primitive.id} | type: ${primitive.type} | ${coord} | ref: ${primitive.ref}${confidence} | grounding: ${primitive.grounding}`;
  });
  return [
    `${VISUAL_PRIMITIVES_START} coord="norm-1000" box_order="xyxy" grounding="${groundingMode}">`,
    ...lines,
    VISUAL_PRIMITIVES_END,
  ].join("\n");
}

function formatStructuredVisionNote(analysis, visionCapabilities) {
  const primitives = normalizeVisualPrimitives(rawVisualPrimitiveItems(analysis), visionCapabilities);
  const sections = [
    `image_overview: ${safeSection(analysis?.image_overview)}`,
    `visible_text: ${safeSection(analysis?.visible_text)}`,
    `objects_and_layout: ${safeSection(analysis?.objects_and_layout)}`,
    `charts_or_data: ${safeSection(analysis?.charts_or_data)}`,
    `user_request: ${safeSection(analysis?.user_request)}`,
    `user_request_answer: ${safeSection(analysis?.user_request_answer)}`,
    `evidence: ${safeSection(analysis?.evidence)}`,
    `uncertainty: ${safeSection(analysis?.uncertainty)}`,
  ];
  const primitiveBlock = formatVisualPrimitives(primitives, visionCapabilities?.groundingMode);
  return truncate(`${sections.join("\n")}\n\n${primitiveBlock}`);
}

function formatInvalidStructuredNote(rawResponse) {
  const primitiveBlock = formatVisualPrimitives([]);
  return truncate([
    "image_overview: structured vision analysis unavailable.",
    "visible_text: none.",
    "objects_and_layout: none.",
    "charts_or_data: none.",
    "user_request: see original message.",
    "user_request_answer: The auxiliary vision model returned invalid structured JSON, so coordinate evidence was not used.",
    `evidence: raw response excerpt: ${truncate(rawResponse, 700)}`,
    "uncertainty: visual primitives unavailable because the response could not be parsed.",
    "",
    primitiveBlock,
  ].join("\n"));
}

export class VisionBridge {
  declare _analysisByPrompt: any;
  declare _callText: any;
  declare _getActiveAgentId: any;
  declare _getUsageLedger: any;
  declare _maxCacheEntries: any;
  declare _noteByPath: any;
  declare _now: any;
  declare _resolveVisionConfig: any;
  declare _visionMaxTokens: any;
  constructor({
    resolveVisionConfig,
    callText = defaultCallText,
    getUsageLedger = null,
    getActiveAgentId = null,
    now = () => Date.now(),
    maxCacheEntries = MAX_CACHE_ENTRIES,
    visionMaxTokens = DEFAULT_VISION_MAX_TOKENS,
  }: any = {}) {
    this._resolveVisionConfig = resolveVisionConfig || (() => null);
    this._callText = callText;
    this._getUsageLedger = typeof getUsageLedger === "function" ? getUsageLedger : () => null;
    this._getActiveAgentId = typeof getActiveAgentId === "function" ? getActiveAgentId : () => null;
    this._now = now;
    this._maxCacheEntries = maxCacheEntries;
    this._visionMaxTokens = positiveInteger(visionMaxTokens) || DEFAULT_VISION_MAX_TOKENS;
    this._analysisByPrompt = new Map();
    this._noteByPath = new Map();
  }

  async prepare({ sessionPath, targetModel, text, images, imageAttachmentPaths, signal }: any = {}) {
    if (!images?.length) return { text, images };
    if (!requiresAuxiliaryVision(targetModel)) return { text, images };
    throwIfAborted(signal);

    const config = this._resolveVisionConfig?.();
    if (!config?.model) {
      throw new Error("vision auxiliary model is required for image input with the current text-only model");
    }
    if (!modelSupportsImage(config.model)) {
      throw new Error("vision auxiliary model must support image input");
    }

    const paths = imageAttachmentPaths?.length ? imageAttachmentPaths : uniquePathsFromText(text);
    const userRequest = normalizeUserRequest(text);
    const notes = [];
    for (let i = 0; i < images.length; i++) {
      const img = images[i];
      throwIfAborted(signal);
      const note = await this._analyzeImage(config, img, i, userRequest, signal, sessionPath);
      const imagePath = paths[i];
      if (imagePath) {
        const entry = {
          note,
          sessionPath: sessionPath || null,
          imagePath,
          userRequest,
          visionModel: compactModelRef(config.model),
          targetModel: compactModelRef(targetModel),
          updatedAt: this._now(),
        };
        this._noteByPath.set(imagePath, entry);
        this._trimNoteCache();
        if (sessionPath) this._persistNote(sessionPath, imagePath, entry);
      }
      notes.push(note);
    }

    return { text, images: undefined, visionNotes: notes };
  }

  async prepareResources({ sessionPath, targetModel, userRequest, text, resources, signal }: any = {}) {
    if (!resources?.length) return { notes: [] };
    if (!requiresAuxiliaryVision(targetModel)) return { notes: [] };
    return this._summarizeResources({
      sessionPath,
      targetModel,
      userRequest: userRequest ?? text,
      resources,
      signal,
    });
  }

  async summarizeResources({ sessionPath, userRequest, text, resources, signal }: any = {}) {
    if (!resources?.length) return { notes: [] };
    return this._summarizeResources({
      sessionPath,
      targetModel: null,
      userRequest: userRequest ?? text,
      resources,
      signal,
    });
  }

  async _summarizeResources({ sessionPath, targetModel, userRequest, resources, signal }: any = {}) {
    throwIfAborted(signal);

    const config = this._resolveVisionConfig?.();
    if (!config?.model) {
      throw new Error("vision auxiliary model is required for image input with the current text-only model");
    }
    if (!modelSupportsImage(config.model)) {
      throw new Error("vision auxiliary model must support image input");
    }

    const request = normalizeUserRequest(userRequest);
    const notes = [];
    for (let i = 0; i < resources.length; i++) {
      const resource = resources[i];
      const key = String(resource?.key || "").trim();
      const img = resource?.image;
      if (!key || !img) continue;

      const existing = this._lookupNote(sessionPath, key);
      if (existing?.note) {
        notes.push({
          key,
          label: resource?.label || key,
          note: existing.note,
          reused: true,
        });
        continue;
      }

      throwIfAborted(signal);
      const note = await this._analyzeImage(config, img, i, request, signal, sessionPath);
      const entry = {
        note,
        sessionPath: sessionPath || null,
        imagePath: key,
        userRequest: request,
        visionModel: compactModelRef(config.model),
        targetModel: compactModelRef(targetModel),
        updatedAt: this._now(),
      };
      this._noteByPath.set(key, entry);
      this._trimNoteCache();
      if (sessionPath) this._persistNote(sessionPath, key, entry);
      notes.push({
        key,
        label: resource?.label || key,
        note,
        reused: false,
      });
    }

    return { notes };
  }

  lookupNote(sessionPath, imagePath) {
    return this._lookupNote(sessionPath, imagePath);
  }

  injectNotes(messages, sessionPath = null) {
    if (!Array.isArray(messages)) return { messages, injected: 0 };
    let injected = 0;

    const next = messages.map((msg) => {
      if (!msg || typeof msg !== "object") return msg;
      if (msg.role !== "user") return msg;
      const text = contentText(msg.content);
      if (!text || text.includes(VISION_CONTEXT_START)) return msg;
      const entries = uniquePathsFromText(text)
        .map((imagePath) => [imagePath, this._lookupNote(sessionPath, imagePath)])
        .filter(([, entry]) => !!entry);
      if (!entries.length) return msg;

      const noteText = entries.map(([, entry], idx) => {
        return `image_${idx + 1}: ${entry.note}`;
      }).join("\n\n");
      const block = `${VISION_CONTEXT_START}\n${noteText}\n${VISION_CONTEXT_END}\n\n`;

      const replacedContent = replaceTextContent(msg.content, (oldText) => {
        const localPaths = uniquePathsFromText(oldText);
        const localHits = entries.filter(([imagePath]) => localPaths.includes(imagePath));
        if (!localHits.length) return oldText;
        injected += localHits.length;
        return `${block}${oldText}`;
      });
      return replacedContent === msg.content ? msg : { ...msg, content: replacedContent };
    });

    return { messages: injected ? next : messages, injected };
  }

  _persistNote(sessionPath, imagePath, entry) {
    const filePath = sessionNotesPath(sessionPath);
    const sessionKey = sessionNotesKey(sessionPath);
    if (!filePath || !sessionKey) return;

    const sidecar = readNotesSidecar(filePath);
    const sessionEntry = sidecar.sessions[sessionKey] || { images: {} };
    const images = sessionEntry.images && typeof sessionEntry.images === "object"
      ? sessionEntry.images
      : {};
    images[imagePath] = {
      note: entry.note,
      imagePath,
      userRequest: entry.userRequest || "",
      visionModel: entry.visionModel || null,
      targetModel: entry.targetModel || null,
      updatedAt: entry.updatedAt,
    };
    sidecar.sessions[sessionKey] = { ...sessionEntry, images };
    writeNotesSidecar(filePath, sidecar);
  }

  _lookupNote(sessionPath, imagePath) {
    const memoryEntry = this._noteByPath.get(imagePath);
    if (memoryEntry && (!sessionPath || !memoryEntry.sessionPath || memoryEntry.sessionPath === sessionPath)) {
      return memoryEntry;
    }
    if (!sessionPath) return null;

    const filePath = sessionNotesPath(sessionPath);
    const sessionKey = sessionNotesKey(sessionPath);
    if (!filePath || !sessionKey) return null;
    const sidecar = readNotesSidecar(filePath);
    const entry = sidecar.sessions?.[sessionKey]?.images?.[imagePath] || null;
    if (!entry?.note) return null;
    const restored = {
      note: entry.note,
      sessionPath,
      imagePath,
      userRequest: entry.userRequest || "",
      visionModel: entry.visionModel || null,
      targetModel: entry.targetModel || null,
      updatedAt: entry.updatedAt || this._now(),
    };
    this._noteByPath.set(imagePath, restored);
    this._trimNoteCache();
    return restored;
  }

  _maxTokensForModel(model) {
    const limit = visionOutputLimit(model);
    return limit ? Math.min(this._visionMaxTokens, limit) : this._visionMaxTokens;
  }

  async _analyzeImage(config, img, index, userRequest, signal, sessionPath = null) {
    const normalizedImg = normalizeModelImageInput(img, index);
    const visionCapabilities = getVisionCapabilities(config.model);
    const key = imagePromptCacheKey(
      normalizedImg,
      userRequest,
      visionModelCacheSignature(config.model, visionCapabilities),
    );
    const cached = this._analysisByPrompt.get(key);
    if (cached) {
      cached.lastUsedAt = this._now();
      return cached.note;
    }

    const note = visionCapabilities
      ? await this._analyzeImageWithPrimitives(config, normalizedImg, userRequest, visionCapabilities, signal, sessionPath)
      : await this._analyzeImageAsNote(config, normalizedImg, userRequest, signal, sessionPath);

    this._analysisByPrompt.set(key, {
      note,
      createdAt: this._now(),
      lastUsedAt: this._now(),
      index,
    });
    this._trimCache();
    return note;
  }

  _usageContextForImageAnalysis(sessionPath) {
    const agentId = this._getActiveAgentId?.() ?? null;
    return {
      source: {
        subsystem: "vision",
        operation: "analyze_image",
        surface: sessionPath ? "session" : "tool",
        trigger: "user",
      },
      attribution: sessionPath
        ? { kind: "session", sessionPath, agentId }
        : { kind: "utility", agentId },
    };
  }

  async _analyzeImageAsNote(config, img, userRequest, signal, sessionPath = null) {
    return truncate(await this._callText({
      api: config.api,
      apiKey: config.api_key,
      baseUrl: config.base_url,
      model: config.model,
      systemPrompt: AUXILIARY_VISION_SYSTEM_PROMPT,
      messages: [{
        role: "user",
        content: [
          {
            type: "text",
            text: [
              "Analyze this image for another text-only model.",
              "Return a concise paper note with these exact sections:",
              "image_overview: fixed basic description of what the image is.",
              "visible_text: important OCR or readable text.",
              "objects_and_layout: important objects, positions, counts, and relationships.",
              "charts_or_data: chart/table/data details if present; otherwise say none.",
              "user_request: restate the user's request in one short sentence.",
              "user_request_answer: answer the user's request using the image when possible.",
              "evidence: the visual evidence supporting that answer.",
              "uncertainty: anything unclear, hidden, or guessed.",
              "Do not mention that you are a tool or a separate model.",
              "",
              `User request:\n${userRequest || "(no explicit text request)"}`,
            ].join("\n"),
          },
          img,
        ],
      }],
      maxTokens: this._maxTokensForModel(config.model),
      timeoutMs: VISION_ANALYSIS_TIMEOUT_MS,
      signal,
      usageLedger: this._getUsageLedger?.(),
      usageContext: this._usageContextForImageAnalysis(sessionPath),
    }));
  }

  async _analyzeImageWithPrimitives(config, img, userRequest, visionCapabilities, signal, sessionPath = null) {
    const primitiveShape = primitivePromptShape(visionCapabilities);
    const responseText = await this._callText({
      api: config.api,
      apiKey: config.api_key,
      baseUrl: config.base_url,
      model: config.model,
      systemPrompt: AUXILIARY_VISION_SYSTEM_PROMPT,
      messages: [{
        role: "user",
        content: [
          {
            type: "text",
            text: [
              "Analyze this image for another text-only model.",
              "Return only one valid JSON object. Do not wrap it in Markdown.",
              "Use this exact shape:",
              "{",
              '  "image_overview": "fixed basic description of what the image is",',
              '  "visible_text": ["important OCR or readable text"],',
              '  "objects_and_layout": "important objects, positions, counts, and relationships",',
              '  "charts_or_data": "chart/table/data details if present; otherwise none",',
              '  "user_request": "restate the user request in one short sentence",',
              '  "user_request_answer": "answer the user request using the image when possible",',
              '  "evidence": "visual evidence supporting that answer",',
              '  "uncertainty": "anything unclear, hidden, or guessed",',
              ...primitiveShape.slice(0, 3),
              "}",
              primitiveShape[3],
              visionCapabilities.points
                ? "You may include point or center coordinates as [x, y] normalized to 0-1000."
                : "Do not output point primitives.",
              "Include only coordinates that matter for the user request or key spatial evidence.",
              "Do not mention that you are a tool or a separate model.",
              "",
              `User request:\n${userRequest || "(no explicit text request)"}`,
            ].join("\n"),
          },
          img,
        ],
      }],
      maxTokens: this._maxTokensForModel(config.model),
      timeoutMs: VISION_ANALYSIS_TIMEOUT_MS,
      signal,
      usageLedger: this._getUsageLedger?.(),
      usageContext: this._usageContextForImageAnalysis(sessionPath),
    });

    const analysis = extractJsonObject(responseText);
    if (!analysis) return formatInvalidStructuredNote(responseText);
    return formatStructuredVisionNote(analysis, visionCapabilities);
  }

  _trimCache() {
    if (this._analysisByPrompt.size <= this._maxCacheEntries) return;
    const entries = [...this._analysisByPrompt.entries()]
      .sort((a, b) => (a[1].lastUsedAt || 0) - (b[1].lastUsedAt || 0));
    for (const [key] of entries.slice(0, this._analysisByPrompt.size - this._maxCacheEntries)) {
      this._analysisByPrompt.delete(key);
    }
  }

  _trimNoteCache() {
    if (this._noteByPath.size <= this._maxCacheEntries) return;
    const entries = [...this._noteByPath.entries()]
      .sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
    for (const [key] of entries.slice(0, this._noteByPath.size - this._maxCacheEntries)) {
      this._noteByPath.delete(key);
    }
  }
}
