/**
 * Gemini-lärar-tier för AAMOS-task-typer (SKIVA 1).
 *
 * - Implementerar samma AamosClient-interface som HttpAamosClient/MockAamosClient.
 * - App + Food API ser ingen skillnad; workern byter bara adapter.
 * - Nycklar finns serversidan; klienten exponeras aldrig i appen.
 * - Kostnad/tokens rapporteras tillbaka så workern kan bokföra i ai_usage_counters.
 * - Global dagsbudget kan sättas via GEMINI_DAILY_BUDGET_USD.
 */
import { z } from "zod";
import {
  TASK_CONTRACTS,
  type AamosTaskType,
  type TaskInput,
  type TaskOutput,
  detectedItemSchema,
  type DetectedItem,
  generateRecipeCandidatesOutput,
  analyzeMealImageOutput,
  readNutritionLabelOutput,
  readExpiryDateOutput,
  readReceiptOutput,
  translateRecipeOutput,
} from "./tasks.js";
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
import type { LocaleContext } from "@app/shared-types";

const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";

/** Språknamn (på svenska) som används i receptöversättningsprompten. */
const LANGUAGE_NAMES: Record<string, string> = {
  en: "engelska",
  da: "danska",
  de: "tyska",
  es: "spanska",
  fi: "finska",
  fr: "franska",
  it: "italienska",
  nb: "norska (bokmål)",
  nl: "nederländska",
  pl: "polska",
  pt: "portugisiska",
  sv: "svenska",
};

/** Custom fetch using Node https module — works around IPv6 hangs on WSL. Follows redirects. */
function customNodeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
  return requestOnce(input, init, 5);
}

function requestOnce(
  input: string | URL | Request,
  init: RequestInit | undefined,
  redirectsLeft: number,
): Promise<Response> {
  return new Promise((resolve, reject) => {
    const url =
      typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
    const u = new URL(url);
    const isHttps = u.protocol === "https:";
    const mod = isHttps ? import("node:https") : import("node:http");
    mod
      .then((httpMod) => {
        const postData = init?.body ? String(init.body) : undefined;
        const headers: Record<string, string> = {};
        if (init?.headers) {
          if (init.headers instanceof Headers) {
            init.headers.forEach((v, k) => {
              headers[k] = v;
            });
          } else if (Array.isArray(init.headers)) {
            (init.headers as [string, string][]).forEach(([k, v]) => {
              if (k != null) headers[k] = v;
            });
          } else {
            Object.assign(headers, init.headers as Record<string, string>);
          }
        }
        if (postData && !headers["content-length"]) {
          headers["content-length"] = String(Buffer.byteLength(postData));
        }
        if (!headers["user-agent"]) {
          headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
        }

        const req = httpMod.request(
          {
            hostname: u.hostname,
            port: u.port || (isHttps ? 443 : 80),
            path: u.pathname + u.search,
            method: init?.method || "GET",
            headers,
            timeout: 120_000,
          },
          (res) => {
            const status = res.statusCode ?? 200;
            const location = res.headers.location;
            if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
              const nextUrl = new URL(location, url).toString();
              requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
                .then(resolve)
                .catch(reject);
              return;
            }

            const chunks: Buffer[] = [];
            res.on("data", (chunk: Buffer) => {
              chunks.push(chunk);
            });
            res.on("end", () => {
              const body = Buffer.concat(chunks);
              resolve(
                new Response(body, {
                  status,
                  statusText: res.statusMessage ?? "OK",
                  headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
                }),
              );
            });
          },
        );
        req.on("error", (err) => reject(err));
        req.on("timeout", () => {
          req.destroy();
          reject(new Error("Request timeout"));
        });
        if (postData) req.write(postData);
        req.end();
      })
      .catch(reject);
  });
}

/** Estimated max cost per image call in USD (pessimistic). */
const COST_ESTIMATE_PER_IMAGE_USD = 0.004;

/** Estimated max cost per simple OCR image call in USD. */
const COST_ESTIMATE_PER_OCR_IMAGE_USD = 0.002;

export interface BudgetStore {
  /** Return current daily spend in USD. */
  getDailySpendUsd(): Promise<number>;
  /** Increment daily spend by amountUsd; return new total. */
  incrementDailySpendUsd(amountUsd: number): Promise<number>;
  /**
   * Optional: called when the daily budget is exceeded. Implementations can
   * raise an alert (e.g. write to Redis / DB) so ops-summary/EOC can surface it.
   */
  raiseBudgetAlarm?(): Promise<void>;
}

/** In-memory budget store for tests / single-process dev. */
export class MemoryBudgetStore implements BudgetStore {
  private spend = 0;
  private alarmRaised = false;
  async getDailySpendUsd(): Promise<number> {
    return this.spend;
  }
  async incrementDailySpendUsd(amountUsd: number): Promise<number> {
    this.spend += amountUsd;
    return this.spend;
  }
  async raiseBudgetAlarm(): Promise<void> {
    this.alarmRaised = true;
  }
  alarmWasRaised(): boolean {
    return this.alarmRaised;
  }
  reset() {
    this.spend = 0;
    this.alarmRaised = false;
  }
}

export interface GeminiAamosClientConfig {
  apiKey: string;
  model?: string;
  timeoutMs?: number;
  dailyBudgetUsd?: number;
  budgetStore?: BudgetStore;
  fetchImpl?: typeof fetch;
  promptVersion?: string;
}

const geminiFridgeResponseSchema = z.object({
  items: z.array(
    z.object({
      produkt: z.string(),
      varumarke: z.string().nullable(),
      kvantitet: z.union([z.number(), z.string()]).nullable(),
      enhet: z.string().nullable(),
      kategori: z.string().nullable(),
      bastaFore: z.string().nullable().optional(),
      sistaForbruk: z.string().nullable().optional(),
      beskrivning: z.string().nullable().optional(),
      konfidens: z.number().min(0).max(1),
    }),
  ),
  imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
});

export function parseDate(value: string | null | undefined): string | null {
  if (!value) return null;
  const normalized = value.trim();
  if (!normalized) return null;
  // Accept ISO dates (YYYY-MM-DD).
  if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
  // Kompakta stämplar utan separator (vanligt på svensk förpackning): YYYYMMDD / YYMMDD.
  if (/^\d{8}$/.test(normalized)) {
    const y = +normalized.slice(0, 4),
      m = +normalized.slice(4, 6),
      d = +normalized.slice(6, 8);
    if (y > 2000 && m >= 1 && m <= 12 && d >= 1 && d <= 31)
      return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
    return null;
  }
  if (/^\d{6}$/.test(normalized)) {
    const y = 2000 + +normalized.slice(0, 2),
      m = +normalized.slice(2, 4),
      d = +normalized.slice(4, 6);
    if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
      return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
    return null;
  }
  // Separatorformat (- / .).
  const parts = normalized.split(/[-/.]/);
  if (parts.length === 3) {
    const [aStr, bStr, cStr] = parts;
    if (!aStr || !bStr || !cStr) return null;
    const a = parseInt(aStr, 10);
    const b = parseInt(bStr, 10);
    const c = parseInt(cStr, 10);
    if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
    // YYYY-MM-DD
    if (a > 2000 && b <= 12 && c <= 31)
      return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
    // DD-MM-YYYY
    if (c > 2000 && b <= 12 && a <= 31)
      return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
    // YY-MM-DD (2-siffrigt år först — vanligast på svensk bäst-före-stämpel).
    if (a <= 99 && b >= 1 && b <= 12 && c >= 1 && c <= 31)
      return `${2000 + a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
  }
  return null;
}

function parseQuantity(value: string | number | null | undefined): {
  quantity: number | null;
  unit: string | null;
} {
  if (value == null) return { quantity: null, unit: null };
  if (typeof value === "number") return { quantity: value, unit: null };
  const text = String(value).trim().replace(/,/g, ".");
  if (!text) return { quantity: null, unit: null };
  const match = text.match(/^(\d+(?:\.\d+)?)\s*(.*)$/);
  if (match && match[1] != null && match[2] != null) {
    const qty = parseFloat(match[1]);
    const unit = match[2].trim();
    return { quantity: Number.isFinite(qty) ? qty : null, unit: unit || null };
  }
  return { quantity: null, unit: text };
}

function normalizeUnit(unit: string | null | undefined): string | null {
  if (unit == null) return null;
  const u = unit.toLowerCase().trim();
  if (u === "g" || u === "gram") return "GRAM";
  if (u === "kg" || u === "kilo" || u === "kilogram") return "KILOGRAM";
  if (u === "ml" || u === "milliliter") return "MILLILITER";
  if (u === "dl" || u === "deciliter") return "DECILITER";
  if (u === "l" || u === "liter") return "LITER";
  if (u === "st" || u === "styck" || u === "piece" || u === "pieces") return "COUNT";
  if (u === "förpackning" || u === "paket" || u === "package") return "PACKAGE";
  if (u === "portion" || u === "portioner") return "PORTION";
  if (u === "klyfta" || u === "klyftor") return "CLOVE";
  if (u === "skiva" || u === "skivor") return "SLICE";
  return null;
}

const MAX_PAST_DAYS = 550; // ~18 mån bakåt: verkligt passerade varor tillåts, men grovt feltolkade årtal inte.
const MAX_FUTURE_DAYS = 1830; // ~5 år framåt: torrvaror kan ha långt bäst-före; längre än så är en feltolkning.

/**
 * Rimlighetsspärr för AI-lästa matvarudatum. Ett datum som ligger orimligt långt
 * bak eller fram i tiden är nästan alltid en feltolkad stämpelkod (t.ex. ett gissat
 * årtal). Hellre inget datum än ett felaktigt — annars kan appen visa en falsk
 * "utgången"/"går ut snart"-varning (spec §13). Format-parsning görs av parseDate;
 * detta filtrerar bara bort grova orimligheter, inte verkligt nyligen passerade varor.
 */
export function isPlausibleFoodDate(
  dateStr: string | null | undefined,
  today: Date = new Date(),
): boolean {
  if (!dateStr) return false;
  const d = new Date(`${dateStr}T00:00:00Z`);
  if (Number.isNaN(d.getTime())) return false;
  const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
  const daysDiff = (d.getTime() - todayUtc) / 86_400_000;
  return daysDiff >= -MAX_PAST_DAYS && daysDiff <= MAX_FUTURE_DAYS;
}

/** parseDate + rimlighetsspärr: returnerar datumet bara om det är rimligt, annars null. */
function plausibleFoodDate(
  dateStr: string | null | undefined,
  today: Date = new Date(),
): string | null {
  return isPlausibleFoodDate(dateStr, today) ? (dateStr ?? null) : null;
}

function toDetectedItems(parsed: z.infer<typeof geminiFridgeResponseSchema>): DetectedItem[] {
  return parsed.items.map((item) => {
    const { quantity, unit } = parseQuantity(item.kvantitet);
    return detectedItemSchema.parse({
      detectedName: item.produkt,
      canonicalIngredientId: null,
      brand: item.varumarke,
      estimatedQuantity: quantity,
      unit: normalizeUnit(unit ?? item.enhet),
      bestBeforeDate: plausibleFoodDate(parseDate(item.bastaFore ?? item.sistaForbruk)),
      confidence: item.konfidens,
      requiresConfirmation: item.konfidens < 0.92,
      boundingBox: null,
    });
  });
}

export class GeminiAamosClient implements AamosClient {
  private readonly cfg: Required<Omit<GeminiAamosClientConfig, "budgetStore" | "fetchImpl">> & {
    fetchImpl: typeof fetch;
    budgetStore?: BudgetStore;
  };

  constructor(config: GeminiAamosClientConfig) {
    this.cfg = {
      apiKey: config.apiKey,
      model: config.model ?? "gemini-3.1-flash-lite",
      timeoutMs: config.timeoutMs ?? 60_000,
      dailyBudgetUsd: config.dailyBudgetUsd ?? 0,
      promptVersion: config.promptVersion ?? "gemini-fridge-v4",
      fetchImpl: config.fetchImpl ?? customNodeFetch,
      budgetStore: config.budgetStore,
    };
  }

  async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
    try {
      const url = `${GEMINI_API_BASE}/models?key=${encodeURIComponent(this.cfg.apiKey)}`;
      const res = await this.cfg.fetchImpl(url, { signal: AbortSignal.timeout(5_000) });
      return { ok: res.ok, detail: `status ${res.status}` };
    } catch (err) {
      return { ok: false, detail: err instanceof Error ? err.message : String(err) };
    }
  }

  async runTask<T extends AamosTaskType>(
    taskType: T,
    input: TaskInput<T>,
    options: AamosCallOptions = {},
  ): Promise<AamosResult<T>> {
    const contract = TASK_CONTRACTS[taskType];
    const parsedInput = contract.input.safeParse(input);
    if (!parsedInput.success) {
      return {
        status: "failed",
        output: null,
        error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
      };
    }

    switch (taskType) {
      case "ANALYZE_FRIDGE_IMAGE":
      case "ANALYZE_PANTRY_IMAGE":
        return this.analyzeStorageImage(
          taskType,
          parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">,
          options,
        ) as Promise<AamosResult<T>>;
      case "GENERATE_RECIPE_CANDIDATES":
        return this.generateRecipeCandidates(
          parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">,
          options,
        ) as Promise<AamosResult<T>>;
      case "ANALYZE_MEAL_IMAGE":
        return this.analyzeMealImage(
          parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">,
          options,
        ) as Promise<AamosResult<T>>;
      case "READ_NUTRITION_LABEL":
        return this.readNutritionLabel(
          parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">,
          options,
        ) as Promise<AamosResult<T>>;
      case "READ_EXPIRY_DATE":
        return this.readExpiryDate(
          parsedInput.data as TaskInput<"READ_EXPIRY_DATE">,
          options,
        ) as Promise<AamosResult<T>>;
      case "READ_RECEIPT":
        return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<
          AamosResult<T>
        >;
      case "TRANSLATE_RECIPE":
        return this.translateRecipe(
          parsedInput.data as TaskInput<"TRANSLATE_RECIPE">,
          options,
        ) as Promise<AamosResult<T>>;
      default:
        return {
          status: "failed",
          output: null,
          error: `${taskType} stöds inte av Gemini-lärar-tier i SKIVA 1.`,
        } as AamosResult<T>;
    }
  }

  private async fetchOneImage(
    url: string,
    timeoutMs: number,
  ): Promise<{ inline_data: { mime_type: string; data: string } }> {
    if (url.startsWith("data:")) {
      const match = url.match(/^data:([^;]+);base64,(.+)$/);
      if (match && match[1] && match[2]) {
        return { inline_data: { mime_type: match[1], data: match[2] } };
      }
      throw new Error(`Ogiltig data-URL: ${url.slice(0, 50)}`);
    }
    const res = await this.cfg.fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
    if (!res.ok) throw new Error(`Kunde inte hämta bild: ${res.status} ${url}`);
    const buf = Buffer.from(await res.arrayBuffer());
    return { inline_data: { mime_type: this.mimeType(buf), data: buf.toString("base64") } };
  }

  private async analyzeStorageImage(
    taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
    input: TaskInput<"ANALYZE_FRIDGE_IMAGE">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"ANALYZE_FRIDGE_IMAGE">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const started = Date.now();

    const imageCount = Math.min(input.imageUrls.length, 6);
    const estimatedCostUsd = imageCount * COST_ESTIMATE_PER_IMAGE_USD;
    if (await this.isOverBudget(estimatedCostUsd)) {
      console.warn(
        "[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
      );
    }

    const imageParts = await Promise.all(
      input.imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, this.cfg.timeoutMs)),
    );

    if (imageParts.length === 0) {
      return {
        status: "failed",
        output: null,
        error: "Global Gemini-dagsbudget är förbrukad.",
      };
    }

    const prompt = this.buildFridgePrompt(taskType, locale, input.locationType);
    const payload = {
      contents: [{ parts: [{ text: prompt }, ...imageParts] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.2,
      },
    };

    const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
    const res = await this.cfg.fetchImpl(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(this.cfg.timeoutMs),
    });

    if (!res.ok) {
      const body = await res.text();
      return {
        status: "failed",
        output: null,
        error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
      };
    }

    const geminiBody = (await res.json()) as {
      candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
      usageMetadata?: {
        promptTokenCount?: number;
        candidatesTokenCount?: number;
        totalTokenCount?: number;
      };
    };
    const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";

    let parsed: z.infer<typeof geminiFridgeResponseSchema>;
    try {
      const json = JSON.parse(text);
      const safe = geminiFridgeResponseSchema.safeParse(json);
      if (!safe.success) {
        return {
          status: "failed",
          output: null,
          error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
        };
      }
      parsed = safe.data;
    } catch {
      return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
    }

    const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
    const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
    const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
    const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
    await this.recordSpend(costUsd);

    const latencyMs = Date.now() - started;
    const items = toDetectedItems(parsed);

    return {
      status: items.length > 0 ? "ok" : "uncertain",
      output: {
        items,
        imageQualityIssues: parsed.imageQualityIssues,
      } as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
      modelVersion: this.cfg.model,
      promptVersion: this.cfg.promptVersion,
      latencyMs,
      costUsd,
      inputTokens,
      outputTokens,
      totalTokens,
    };
  }

  private async runVisionTask<T extends AamosTaskType>(
    taskType: T,
    imageUrls: string[],
    prompt: string,
    outputSchema: z.ZodSchema<unknown>,
    mapOutput: (parsed: unknown) => TaskOutput<T>,
    options: AamosCallOptions,
    promptVersion: string,
    costEstimatePerImage: number,
  ): Promise<AamosResult<T>> {
    const locale = options.localeContext ?? this.defaultLocale();
    const started = Date.now();

    const imageCount = Math.min(imageUrls.length, 6);
    const estimatedCostUsd = imageCount * costEstimatePerImage;
    if (await this.isOverBudget(estimatedCostUsd)) {
      console.warn(
        "[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
      );
    }

    const imageParts = await this.fetchImageParts(imageUrls, this.cfg.timeoutMs);
    if (imageParts.length === 0) {
      return {
        status: "failed",
        output: null,
        error: "Global Gemini-dagsbudget är förbrukad.",
      } as AamosResult<T>;
    }

    const payload = {
      contents: [{ parts: [{ text: prompt }, ...imageParts] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.2,
      },
    };

    const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
    const res = await this.cfg.fetchImpl(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(this.cfg.timeoutMs),
    });

    if (!res.ok) {
      const body = await res.text();
      return {
        status: "failed",
        output: null,
        error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
      } as AamosResult<T>;
    }

    const geminiBody = (await res.json()) as {
      candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
      usageMetadata?: {
        promptTokenCount?: number;
        candidatesTokenCount?: number;
        totalTokenCount?: number;
      };
    };
    const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";

    let parsed: unknown;
    try {
      const json = JSON.parse(text);
      const safe = outputSchema.safeParse(json);
      if (!safe.success) {
        return {
          status: "failed",
          output: null,
          error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
        } as AamosResult<T>;
      }
      parsed = safe.data;
    } catch {
      return {
        status: "failed",
        output: null,
        error: "Gemini-svar var inte giltig JSON.",
      } as AamosResult<T>;
    }

    const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
    const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
    const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
    const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
    await this.recordSpend(costUsd);

    const latencyMs = Date.now() - started;
    const output = mapOutput(parsed) as TaskOutput<T>;

    return {
      status: "ok",
      output,
      modelVersion: this.cfg.model,
      promptVersion,
      latencyMs,
      costUsd,
      inputTokens,
      outputTokens,
      totalTokens,
    };
  }

  private async analyzeMealImage(
    input: TaskInput<"ANALYZE_MEAL_IMAGE">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"ANALYZE_MEAL_IMAGE">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const prompt = this.buildMealPrompt(locale, input.recipeContext);
    return this.runVisionTask(
      "ANALYZE_MEAL_IMAGE",
      input.imageUrls,
      prompt,
      analyzeMealImageOutput,
      (parsed) => parsed as TaskOutput<"ANALYZE_MEAL_IMAGE">,
      options,
      "gemini-meal-v1",
      COST_ESTIMATE_PER_IMAGE_USD,
    );
  }

  private async readNutritionLabel(
    input: TaskInput<"READ_NUTRITION_LABEL">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"READ_NUTRITION_LABEL">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const prompt = this.buildNutritionPrompt(locale);
    return this.runVisionTask(
      "READ_NUTRITION_LABEL",
      input.imageUrls,
      prompt,
      readNutritionLabelOutput,
      (parsed) => parsed as TaskOutput<"READ_NUTRITION_LABEL">,
      options,
      "gemini-nutrition-v1",
      COST_ESTIMATE_PER_OCR_IMAGE_USD,
    );
  }

  private async readExpiryDate(
    input: TaskInput<"READ_EXPIRY_DATE">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"READ_EXPIRY_DATE">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const prompt = this.buildExpiryPrompt(locale);
    return this.runVisionTask(
      "READ_EXPIRY_DATE",
      input.imageUrls,
      prompt,
      readExpiryDateOutput,
      (parsed) => parsed as TaskOutput<"READ_EXPIRY_DATE">,
      options,
      "gemini-expiry-v1",
      COST_ESTIMATE_PER_OCR_IMAGE_USD,
    );
  }

  private async readReceipt(
    input: TaskInput<"READ_RECEIPT">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"READ_RECEIPT">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const prompt = this.buildReceiptPrompt(locale);
    return this.runVisionTask(
      "READ_RECEIPT",
      input.imageUrls,
      prompt,
      readReceiptOutput,
      (parsed) => parsed as TaskOutput<"READ_RECEIPT">,
      options,
      "gemini-receipt-v1",
      COST_ESTIMATE_PER_IMAGE_USD,
    );
  }

  private async fetchImageParts(
    imageUrls: string[],
    timeoutMs: number,
  ): Promise<Array<{ inline_data: { mime_type: string; data: string } }>> {
    return Promise.all(imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)));
  }

  private async generateRecipeCandidates(
    input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
    options: AamosCallOptions,
  ): Promise<AamosResult<"GENERATE_RECIPE_CANDIDATES">> {
    const locale = options.localeContext ?? this.defaultLocale();
    const started = Date.now();

    // Estimate cost: ~0.003 USD per candidate (text-only, pessimistic)
    const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
    const estimatedCostUsd = totalCandidates * 0.003;
    if (await this.isOverBudget(estimatedCostUsd)) {
      console.warn(
        "[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
      );
    }

    const prompt = this.buildRecipeGenerationPrompt(input, locale);
    const payload = {
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.3,
      },
    };

    const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
    const res = await this.cfg.fetchImpl(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(this.cfg.timeoutMs),
    });

    if (!res.ok) {
      const body = await res.text();
      return {
        status: "failed",
        output: null,
        error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
      };
    }

    const geminiBody = (await res.json()) as {
      candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
      usageMetadata?: {
        promptTokenCount?: number;
        candidatesTokenCount?: number;
        totalTokenCount?: number;
      };
    };
    const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";

    let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
    try {
      const json = JSON.parse(text);
      // Defensiv normalisering: Gemini kan returnera rejectedPrompts som objekt istället för strängar.
      if (Array.isArray(json.rejectedPrompts)) {
        json.rejectedPrompts = json.rejectedPrompts.map((p: unknown) =>
          typeof p === "string" ? p : JSON.stringify(p),
        );
      }
      const safe = generateRecipeCandidatesOutput.safeParse(json);
      if (!safe.success) {
        return {
          status: "failed",
          output: null,
          error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
        };
      }
      parsed = safe.data;
    } catch {
      return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
    }

    const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
    const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
    const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
    const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
    await this.recordSpend(costUsd);

    // Normalisera enheter defensivt: Gemini kan ibland returnera "dl"/"msk"/"st".
    for (const c of parsed.candidates) {
      for (const ing of c.ingredients) {
        const normalized = normalizeUnit(String(ing.unit));
        if (normalized) ing.unit = normalized as typeof ing.unit;
      }
    }

    const latencyMs = Date.now() - started;

    return {
      status: parsed.candidates.length > 0 ? "ok" : "uncertain",
      output: parsed as TaskOutput<"GENERATE_RECIPE_CANDIDATES">,
      modelVersion: this.cfg.model,
      promptVersion: "gemini-recipe-gen-v1",
      latencyMs,
      costUsd,
      inputTokens,
      outputTokens,
      totalTokens,
    };
  }

  private async translateRecipe(
    input: TaskInput<"TRANSLATE_RECIPE">,
    _options: AamosCallOptions,
  ): Promise<AamosResult<"TRANSLATE_RECIPE">> {
    const started = Date.now();
    const approxChars =
      input.title.length +
      (input.description?.length ?? 0) +
      input.steps.reduce((s, st) => s + st.instruction.length + (st.tip?.length ?? 0), 0);
    if (await this.isOverBudget((approxChars / 4) * 2 * 1.5e-6)) {
      console.warn(
        "[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, översättning fortsätter.",
      );
    }

    const prompt = this.buildTranslatePrompt(input);
    const payload = {
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: { responseMimeType: "application/json", temperature: 0.2 },
    };
    const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
    const res = await this.cfg.fetchImpl(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(this.cfg.timeoutMs),
    });
    if (!res.ok) {
      const body = await res.text();
      return { status: "failed", output: null, error: `Gemini ${res.status}: ${body.slice(0, 500)}` };
    }
    const geminiBody = (await res.json()) as {
      candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
      usageMetadata?: {
        promptTokenCount?: number;
        candidatesTokenCount?: number;
        totalTokenCount?: number;
      };
    };
    const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
    let parsed: z.infer<typeof translateRecipeOutput>;
    try {
      const json = JSON.parse(text);
      const safe = translateRecipeOutput.safeParse(json);
      if (!safe.success) {
        return {
          status: "failed",
          output: null,
          error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
        };
      }
      parsed = safe.data;
    } catch {
      return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
    }
    const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
    const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
    const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
    const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
    await this.recordSpend(costUsd);
    return {
      status: "ok",
      output: parsed as TaskOutput<"TRANSLATE_RECIPE">,
      modelVersion: this.cfg.model,
      promptVersion: "gemini-translate-v1",
      latencyMs: Date.now() - started,
      costUsd,
      inputTokens,
      outputTokens,
      totalTokens,
    };
  }

  private buildTranslatePrompt(input: TaskInput<"TRANSLATE_RECIPE">): string {
    const tag = input.targetLanguageTag.split("-")[0] ?? input.targetLanguageTag;
    const langName = LANGUAGE_NAMES[tag] ?? input.targetLanguageTag;
    const src = {
      title: input.title,
      description: input.description,
      storageGuidance: input.storageGuidance,
      steps: input.steps.map((s) => ({
        stepNumber: s.stepNumber,
        instruction: s.instruction,
        tip: s.tip,
      })),
    };
    return `Du är en professionell kock och översättare. Översätt receptet nedan från svenska till ${langName}.

REGLER (mycket viktigt):
- Behåll ALLA siffror EXAKT (temperaturer, tider, mängder, portioner) — ändra aldrig ett tal.
- Behåll exakt samma antal steg och exakt samma "stepNumber" som källan.
- Översätt "title", "description", "storageGuidance" samt varje stegs "instruction" och "tip".
- Är ett fält null i källan ska det vara null i svaret (även "tip").
- Naturlig, idiomatisk ${langName} — så som ett recept faktiskt skrivs i det landet. Inga kommentarer, bara översättningen.

Svara ENDAST med giltig JSON enligt exakt detta schema:
{
  "title": string,
  "description": string | null,
  "storageGuidance": string | null,
  "steps": [ { "stepNumber": number, "instruction": string, "tip": string | null } ],
  "confidence": number
}

Källrecept (svenska):
${JSON.stringify(src, null, 2)}`;
  }

  private buildRecipeGenerationPrompt(
    input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
    locale: LocaleContext,
  ): string {
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    const catalog = input.canonicalIngredientsCatalog
      .map(
        (i) =>
          `- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`,
      )
      .join("\n");

    const targets = input.targetMatrix
      .map((t) => `- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`)
      .join("\n");

    const constraints = input.constraints;

    return `Du är en erfaren svensk matskribent som skriver vardagsrecept för svenska hushåll.

VIKTIGAST: Använd ENDAST ingredienser från katalogen nedan. Hitta ALDRIG på ingredienser som inte finns i listan. Om ett recept behöver något som saknas, hoppa över det receptet och lägg till en notering i rejectedPrompts.

Tillåtna ingredienser (canonical_ingredients):
${catalog}

Önskade recept:
${targets}

Begränsningar:
- max förberedelsetid: ${constraints.maxPrepTimeMinutes ?? 60} min
- max koktid: ${constraints.maxCookTimeMinutes ?? 45} min
- portioner: ${constraints.portions ?? 4}
- max kryddnivå: ${constraints.spiceLevelMax ?? 3}
- undvik: ${constraints.avoidIngredients.join(", ") || "(ingen)"}

Regler:
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
- MATSÄKERHET – recept som innehåller rå fågel, rått fläsk/köttfärs, rå fisk/skaldjur eller råa ägg MÅSTE innehålla ett explicit steg som beskriver att proteinet blir genomstekt/genomkokt. Använd formuleringar som: "stek tills kycklingen är genomstekt och inte längre rosa i mitten", "koka tills fisken flagnar", "stek tills köttfärsen är genomstekt", eller ange kärn-/innertemperatur i °C. Det räcker INTE med enbart utseende (t.ex. "gyllenbrun") eller enbart tid.
- Använd ENDAST dessa exakta enhetskoder: GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE. Aldrig "g", "dl", "msk", "tsk", "st".
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
- Varje ingrediens MÅSTE finnas i katalogen ovan.
- Svara ENDAST med giltig JSON i exakt detta format:

{
  "candidates": [
    {
      "titleSv": "...",
      "descriptionSv": "...",
      "cuisine": "swedish",
      "mealTypes": ["dinner"],
      "prepTimeMinutes": 15,
      "cookTimeMinutes": 30,
      "portions": 4,
      "spiceLevel": 1,
      "ingredients": [
        {
          "canonicalIngredientId": "kycklingfile",
          "displayNameSv": "kycklingfilé",
          "quantity": 500,
          "unit": "GRAM",
          "optional": false,
          "note": null
        }
      ],
      "steps": [
        {
          "instructionSv": "...",
          "timerSeconds": null,
          "temperatureC": null,
          "tip": null
        }
      ],
      "storageGuidanceSv": "...",
      "mealPrepFriendly": false,
      "freezerFriendly": false,
      "confidence": 0.95
    }
  ],
  "rejectedPrompts": []
}

Språk: ${lang}.`;
  }

  private buildFridgePrompt(
    taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
    locale: LocaleContext,
    locationType: string,
  ): string {
    const place = taskType === "ANALYZE_FRIDGE_IMAGE" ? "kylskåpet" : "skafferiet";
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    const today = new Date().toISOString().slice(0, 10);
    return `Du är en noggrann livsmedelsassistent för svenska hushåll. Användaren har fotat ${place} (${locationType}).

Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med:
- produkt: produktens namn på svenska (t.ex. "Mellanmjölk", "Smör", "Vispgrädde")
- varumarke: varumärke om det syns tydligt, annars null
- kvantitet: en siffra – helst den faktiska vikten/volymen (t.ex. 500, 4, 1), annars antalet hela förpackningar som heltal
- enhet: g, ml, dl, l eller st. Använd "st" när du räknar hela förpackningar/burkar/flaskor. Undvik "förpackning"/"paket".
- kategori: t.ex. mejeri, frukt, grönt, kött, skafferi
- bastaFore: bäst-före-datum om det går att läsa (YYYY-MM-DD), annars null
- sistaForbruk: sista förbrukningsdatum om det går att läsa (YYYY-MM-DD), annars null
- konfidens: 0.0–1.0

Viktigt:
- Mängd & enhet – följ denna ordning:
  1) Kan du LÄSA vikt/volym på förpackningen (t.ex. "500 g", "1 l", "4 dl") → använd exakt den siffran och enheten (g, ml, dl, l).
  2) Annars: RÄKNA antalet hela förpackningar/burkar/flaskor du ser → heltal med enhet "st" (t.ex. 3 burkar staplade på varandra = kvantitet 3, enhet "st").
  3) Är en förpackning tydligt öppnad/delvis tom och du kan uppskatta hur mycket som är KVAR → ange det i dl eller gram (t.ex. 3 dl), aldrig som en andel av en förpackning.
  4) Vet du inte mängden → kvantitet 1, enhet "st".
  Hitta ALDRIG på decimaler av "paket"/"förpackning" (aldrig "0,6 förpackning" eller "0,8 paket"). Tryckta förpackningsantal som "24-pack" ska inte tolkas som kvantitet.
- Flera bilder: får du flera foton av samma plats (t.ex. samma hylla från olika vinklar), lista varje verklig vara EN gång – inte en gång per bild.
- Svenska mjölk- och mejerifärger: röd förpackning = standardmjölk 3%, grön = mellanmjölk 1,5%, blå = lättmjölk 0,5%. Använd färgen för att avgöra fetthalt.
- Datum: idag är det ${today}. Svenska stämplar skrivs oftast DDMMYY eller YYMMDD (t.ex. "040926" = 4 sept 2026). Tolka ALLTID årtalet som det närmaste rimliga året runt idag — ett bäst-före ligger nära nutid, aldrig flera år bort. Läs av datum som står vid "bäst före"/"sista förbrukning" eller är tydligt utskrivna. Är siffrorna en ren tillverknings-/partikod utan datummening, eller helt oläsliga → null.
- Hellre lägre konfidens än en exakt gissning när du är osäker.

Svara ENDAST med giltig JSON i exakt detta format:
{
  "items": [...],
  "imageQualityIssues": []
}
imageQualityIssues kan innehålla någon av: "dark", "blurry", "occlusion", "too_far".

Språk: ${lang}.`;
  }

  private buildMealPrompt(locale: LocaleContext, recipeContext: unknown): string {
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    const ctx = recipeContext as {
      recipeId?: string;
      titleSv?: string;
      nutritionPerPortion?: Record<string, number>;
      portions?: number;
    } | null;

    let contextLine = "";
    if (ctx?.titleSv) {
      contextLine = `Användaren säger att måltiden ska matcha receptet "${ctx.titleSv}" (${ctx.portions ?? "?"} portioner). `;
    }

    return `Du är en svensk näringsassistent. ${contextLine}Bilden visar en serverad måltid.

Uppskatta:
- matchesRecipeContext: true/false/null – matchar bilden receptet användaren nämnde?
- portionFractionEstimate: hur stor del av en normalportion ser det ut att vara? 0.0–5.0, eller null.
- kcalRange: { min, max, mostLikely } för hela måltiden, eller null.
- components: lista med uppskattade komponenter (namn, ev. canonicalIngredientId som null, estimatedGrams, confidence).
- confidence: 0.0–1.0 för hela tolkningen.

Svara ENDAST med giltig JSON som matchar detta schema:
{
  "matchesRecipeContext": boolean | null,
  "portionFractionEstimate": number | null,
  "kcalRange": { "min": number, "max": number, "mostLikely": number } | null,
  "components": [
    { "name": string, "canonicalIngredientId": string | null, "estimatedGrams": number | null, "confidence": number }
  ],
  "confidence": number
}

Språk: ${lang}.`;
  }

  private buildNutritionPrompt(locale: LocaleContext): string {
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Du kan få en eller flera bilder av SAMMA produkt – typiskt framsidan (produktnamn/varumärke) och baksidan (näringsdeklaration + ingredienslista). Läs av all synlig information över ALLA bilder och slå ihop den till ett svar.

Läs av exakt det som står på förpackningen. Gissa aldrig värden som inte syns. Returnera:
- basis: "per_100_g", "per_100_ml" eller "per_portion" – vad gäller näringstabellen?
- values: { kcal, proteinG, carbsG, fatG, saturatedFatG, monounsaturatedFatG, polyunsaturatedFatG, fiberG, sugarG, saltG } – null om ej synligt. Läs ALLA "varav"-rader under Fett: varav mättat (saturatedFatG), varav enkelomättat (monounsaturatedFatG), varav fleromättat (polyunsaturatedFatG).
- ingredientsText: rå text med ingredienser (från baksidan), eller null.
- allergensDeclared: lista med allergener som explicit nämns (t.ex. ["gluten", "mjölk", "nötter"]).
- gtin: streckkod/EAN om synlig, annars null.
- productName: produktnamn (oftast på framsidan) om synligt, annars null.
- brand: varumärke om synligt, annars null.
- confidence: 0.0–1.0.

Svara ENDAST med giltig JSON som matchar detta schema:
{
  "basis": "per_100_g" | "per_100_ml" | "per_portion" | null,
  "values": { "kcal": number|null, "proteinG": number|null, "carbsG": number|null, "fatG": number|null, "saturatedFatG": number|null, "monounsaturatedFatG": number|null, "polyunsaturatedFatG": number|null, "fiberG": number|null, "sugarG": number|null, "saltG": number|null } | null,
  "ingredientsText": string | null,
  "allergensDeclared": string[],
  "gtin": string | null,
  "productName": string | null,
  "brand": string | null,
  "confidence": number
}

Språk: ${lang}.`;
  }

  private buildExpiryPrompt(locale: LocaleContext): string {
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    const today = new Date().toISOString().slice(0, 10);
    return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Bilden visar ett bäst-före-datum eller sista förbrukningsdatum.

Returnera:
- date: datum i format YYYY-MM-DD, eller null om ej läsbart. Idag är det ${today}. Svenska stämplar är oftast DDMMYY/YYMMDD — tolka årtalet som närmaste rimliga år runt idag, aldrig flera år bort.
- dateKind: "best_before" för "bäst före", "use_by" för "sista förbrukningsdag", annars null.
- confidence: 0.0–1.0.

Svara ENDAST med giltig JSON:
{
  "date": "YYYY-MM-DD" | null,
  "dateKind": "best_before" | "use_by" | null,
  "confidence": number
}

Språk: ${lang}.`;
  }

  private buildReceiptPrompt(locale: LocaleContext): string {
    const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
    return `Du är en OCR-assistent för svenska kvitton. Bilden visar ett butikskvitto.

Läs av:
- storeName: butikens namn, eller null.
- purchaseDate: datum i format YYYY-MM-DD, eller null.
- lines: varje rad med { rawText (exakt text), normalizedName (förenklat livsmedelsnamn eller null), canonicalIngredientId (alltid null), quantity (antal/enhet om går att utläsa, annars null), unit (enhetskod eller null), unitPriceMinor (ören per styck om synligt, annars null), totalPriceMinor (radens totalpris i ören om synligt, annars null), isDiscount (true om det är en rabatt/avdrag-rad), confidence (0.0–1.0) }.
- totalMinor: kvittots totalsumma i ören, eller null.
- discountTotalMinor: total rabatt i ören, eller null.
- confidence: 0.0–1.0 för hela tolkningen.

Svara ENDAST med giltig JSON som matchar detta schema:
{
  "storeName": string | null,
  "purchaseDate": "YYYY-MM-DD" | null,
  "lines": [
    {
      "rawText": string,
      "normalizedName": string | null,
      "canonicalIngredientId": null,
      "quantity": number | null,
      "unit": string | null,
      "unitPriceMinor": number | null,
      "totalPriceMinor": number | null,
      "isDiscount": boolean,
      "confidence": number
    }
  ],
  "totalMinor": number | null,
  "discountTotalMinor": number | null,
  "confidence": number
}

Enhet ("unit"): översätt kvittots enhet till EXAKT en av dessa koder, annars null:
GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE.
Vanliga kvitto-enheter mappas så här: "g"→GRAM, "kg"→KILOGRAM, "l"→LITER, "dl"→DECILITER, "ml"→MILLILITER, "st"/"styck"→COUNT, "förp"/"paket"/"påse"→PACKAGE, "burk"→CAN. Kan du inte avgöra enheten: sätt null (aldrig en påhittad kod). quantity är alltid ett decimaltal.
Språk: ${lang}.`;
  }

  private defaultLocale(): LocaleContext {
    return {
      languageTag: "sv-SE",
      regionCode: "SE",
      timeZone: "Europe/Stockholm",
      measurementSystem: "METRIC",
      temperatureUnit: "CELSIUS",
      currencyCode: "SEK",
    };
  }

  private mimeType(buf: Buffer): string {
    if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg";
    if (buf.slice(0, 8).toString("hex") === "89504e470d0a1a0a") return "image/png";
    if (buf.slice(0, 4).toString("ascii") === "RIFF") return "image/webp";
    return "image/jpeg";
  }

  private estimateCostUsd(inputTokens: number, outputTokens: number): number {
    // Gemini 3.1 Flash Lite standard-tier pricing (2026): $0.25/1M input, $1.50/1M output.
    // (Matcha GEMINI_MODEL i .env / config.ts – kör vi 3.1, ska kostnaden räknas på 3.1.)
    return inputTokens * 0.25e-6 + outputTokens * 1.5e-6;
  }

  private async isOverBudget(estimateUsd: number): Promise<boolean> {
    if (this.cfg.dailyBudgetUsd <= 0 || !this.cfg.budgetStore) return false;
    const current = await this.cfg.budgetStore.getDailySpendUsd();
    const over = current + estimateUsd > this.cfg.dailyBudgetUsd;
    if (over && this.cfg.budgetStore.raiseBudgetAlarm) {
      await this.cfg.budgetStore.raiseBudgetAlarm();
    }
    return over;
  }

  private async recordSpend(costUsd: number): Promise<void> {
    if (this.cfg.budgetStore && costUsd > 0) {
      await this.cfg.budgetStore.incrementDailySpendUsd(costUsd);
    }
  }
}
