import {
  OpenApiGeneratorV31,
  OpenAPIRegistry,
  extendZodWithOpenApi,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
import { USERNAME_PATTERN } from "@/lib/username";

// Nothing at module level — Next.js evaluates route modules at build time to
// determine static vs dynamic, which would run registry.register() before
// extendZodWithOpenApi patches the Zod prototype. Wrap everything lazily.
let cachedSpec: object | null = null;

export function generateOpenApiSpec(): object {
  if (cachedSpec) return cachedSpec;

  extendZodWithOpenApi(z);
  const registry = new OpenAPIRegistry();

  const security: Array<Record<string, string[]>> = [{ SessionCookie: [] }, { BearerApiKey: [] }];

  registry.registerComponent("securitySchemes", "SessionCookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token" });
  registry.registerComponent("securitySchemes", "BearerApiKey", { type: "http", scheme: "bearer", bearerFormat: "ek_..." });

  const DietaryTagsRef = registry.register("DietaryTags", z.object({
    vegan: z.boolean().optional(), vegetarian: z.boolean().optional(),
    glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(),
    nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(),
  }));

  const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({
    id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
    unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(),
  }));

  const RecipeStepRef = registry.register("RecipeStep", z.object({
    id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(),
    photoUrl: z.string().nullable(), appliesTo: z.array(z.string()),
  }));

  const RecipePhotoRef = registry.register("RecipePhoto", z.object({
    id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(), sizeMb: z.number().int(),
  }));
  const RecipeBatchDishRef = registry.register("RecipeBatchDish", z.object({
    id: z.string(), name: z.string(), description: z.string().nullable(), order: z.number().int(),
    fridgeDays: z.number().int(), freezerFriendly: z.boolean(), freezerNote: z.string().nullable(),
    dayOfInstructions: z.string(),
  }));

  const NutritionInputRef = registry.register("NutritionInput", z.object({
    calories: z.number().min(0).max(10000),
    proteinG: z.number().min(0).max(1000),
    carbsG: z.number().min(0).max(1000),
    fatG: z.number().min(0).max(1000),
    fiberG: z.number().min(0).max(1000),
    sodiumMg: z.number().min(0).max(100000),
  }).describe("Per serving."));

  const RecipeRef = registry.register("Recipe", z.object({
    id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
    baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
    prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
    coverIcon: z.string().nullable().describe("Cover placeholder icon key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."),
    coverColor: z.string().nullable().describe("Cover placeholder gradient key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."),
    isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
    nutritionManual: z.boolean().describe("True if nutritionData was entered by the author rather than AI-estimated."),
    dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
    ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
    photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
  }));

  const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
    id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
    baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
    prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
    language: z.string().nullable(), sourceUrl: z.string().nullable(),
    coverIcon: z.string().nullable(), coverColor: z.string().nullable(),
    isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
    nutritionManual: z.boolean(),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
  }).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET)."));

  const CreateRecipeRef = registry.register("CreateRecipe", z.object({
    title: z.string().min(1).max(200),
    description: z.string().max(2000).optional(),
    baseServings: z.number().int().min(1).max(100).default(4),
    recipeType: z.enum(["dish", "drink"]).default("dish"),
    visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
    difficulty: z.enum(["easy", "medium", "hard"]).optional(),
    prepMins: z.number().int().min(0).max(1440).optional(),
    cookMins: z.number().int().min(0).max(1440).optional(),
    tags: z.array(z.string().min(1).max(50)).max(20).default([]),
    aiGenerated: z.boolean().optional(),
    language: z.string().max(10).optional(),
    sourceUrl: z.string().url().max(2000).optional(),
    dietaryTags: DietaryTagsRef.optional(),
    ingredients: z.array(z.object({
      rawName: z.string().min(1).max(200),
      quantity: z.union([z.number(), z.string()]).optional(),
      unit: z.string().max(50).optional(),
      note: z.string().max(500).optional(),
      order: z.number().int().default(0),
    })).max(100).default([]),
    steps: z.array(z.object({
      instruction: z.string().min(1).max(2000),
      timerSeconds: z.number().int().min(0).max(86400).optional(),
      order: z.number().int().optional(),
      appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
    })).max(100).default([]),
    photos: z.array(z.object({
      key: z.string().min(1).max(500),
      isCover: z.boolean().default(false),
      sizeMb: z.number().int().min(0).max(50).default(0),
    })).max(20).default([]),
    coverIcon: z.string().max(50).nullable().optional(),
    coverColor: z.string().max(50).nullable().optional(),
    isBatchCook: z.boolean().default(false),
    dishes: z.array(z.object({
      name: z.string().min(1).max(100),
      description: z.string().max(500).optional(),
      fridgeDays: z.number().int().min(1).max(14),
      freezerFriendly: z.boolean().default(false),
      freezerNote: z.string().max(300).optional(),
      dayOfInstructions: z.string().min(1).max(1000),
    })).max(10).default([]),
    nutrition: NutritionInputRef.optional().describe("Manually-entered nutrition — sets nutritionManual and disables the AI-estimate action on the recipe page."),
  }));

  const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({
    title: z.string().min(1).max(200).optional(),
    description: z.string().max(2000).nullable().optional(),
    baseServings: z.number().int().min(1).max(100).optional(),
    recipeType: z.enum(["dish", "drink"]).optional(),
    visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
    prepMins: z.number().int().min(0).max(1440).nullable().optional(),
    cookMins: z.number().int().min(0).max(1440).nullable().optional(),
    tags: z.array(z.string().min(1).max(50)).max(20).optional(),
    language: z.string().max(10).nullable().optional(),
    sourceUrl: z.string().url().max(2000).nullable().optional(),
    dietaryTags: DietaryTagsRef.optional(),
    ingredients: z.array(z.object({
      rawName: z.string().min(1).max(200),
      quantity: z.union([z.number(), z.string()]).optional(),
      unit: z.string().max(50).optional(),
      note: z.string().max(500).optional(),
      order: z.number().int().default(0),
    })).max(100).optional(),
    steps: z.array(z.object({
      instruction: z.string().min(1).max(2000),
      timerSeconds: z.number().int().min(0).max(86400).optional(),
      order: z.number().int(),
      appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
    })).max(100).optional(),
    photos: z.array(z.object({
      key: z.string().min(1).max(500),
      isCover: z.boolean().default(false),
      sizeMb: z.number().int().min(0).max(50).default(0),
    })).max(20).optional(),
    coverIcon: z.string().max(50).nullable().optional(),
    coverColor: z.string().max(50).nullable().optional(),
    isBatchCook: z.boolean().optional(),
    dishes: z.array(z.object({
      name: z.string().min(1).max(100),
      description: z.string().max(500).optional(),
      fridgeDays: z.number().int().min(1).max(14),
      freezerFriendly: z.boolean().default(false),
      freezerNote: z.string().max(300).optional(),
      dayOfInstructions: z.string().min(1).max(1000),
    })).max(10).optional(),
    nutrition: NutritionInputRef.nullable().optional().describe("Set to null to clear manually-entered nutrition and re-enable the AI-estimate action."),
  }));

  const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));

  const CommentRef = registry.register("Comment", z.object({
    id: z.string(), userId: z.string(),
    content: z.string(), parentId: z.string().nullable(),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
    userName: z.string().nullable(), userUsername: z.string().nullable(), userAvatarUrl: z.string().nullable(),
  }));

  const CollectionRecipeEntryRef = registry.register("CollectionRecipeEntry", z.object({
    collectionId: z.string(), recipeId: z.string(), addedAt: z.string().datetime(),
    recipe: RecipeRef,
  }));
  const CollectionRef = registry.register("Collection", z.object({
    id: z.string(), userId: z.string(), name: z.string(),
    description: z.string().nullable(), notes: z.string().nullable(), tags: z.array(z.string()),
    visibility: z.enum(["private", "unlisted", "public", "followers"]),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
    recipes: z.array(CollectionRecipeEntryRef),
  }));

  const MealPlanFullEntryRef = registry.register("MealPlanFullEntry", z.object({
    id: z.string(), mealPlanId: z.string(),
    day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
    mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
    recipeId: z.string().nullable(), batchDishId: z.string().nullable(),
    servings: z.number().int(), note: z.string().nullable(),
    recipe: RecipeRef.nullable(),
  }));
  const MealPlanRef = registry.register("MealPlan", z.object({
    id: z.string().optional(), userId: z.string().optional(), weekStart: z.string(),
    isPublic: z.boolean().optional(), createdAt: z.string().datetime().optional(),
    entries: z.array(MealPlanFullEntryRef),
  }));

  const PantryItemRef = registry.register("PantryItem", z.object({
    id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
    unit: z.string().nullable(), notes: z.string().nullable(), aisle: z.string().nullable(),
    expiresAt: z.string().datetime().nullable(),
  }));

  const ShoppingListRef = registry.register("ShoppingList", z.object({
    id: z.string(), userId: z.string().nullable(), name: z.string(), isPublic: z.boolean(), publicEditable: z.boolean(),
    generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
    items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean(), inPantry: z.boolean(), sortOrder: z.number().int() })),
  }));

  const ApiKeyRef = registry.register("ApiKey", z.object({
    id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), lastUsedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
  }));

  const CreateApiKeyResponseRef = registry.register("CreateApiKeyResponse", z.object({
    id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), key: z.string().describe("Full key — shown once"), createdAt: z.string().datetime(),
  }));

  const AiGeneratedRef = registry.register("AiGeneratedRecipe", z.object({
    title: z.string(), description: z.string(), baseServings: z.number(),
    difficulty: z.enum(["easy", "medium", "hard"]),
    prepMins: z.number().nullable(), cookMins: z.number().nullable(),
    dietaryTags: DietaryTagsRef,
    ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable() })),
    steps: z.array(z.object({ instruction: z.string(), timerSeconds: z.number().nullable() })),
  }));

  const PaginatedRecipes = z.object({
    data: z.array(RecipeSummaryRef),
    limit: z.number(),
    offset: z.number(),
  });

  const PaginatedCollections = z.object({
    data: z.array(CollectionRef),
    total: z.number(),
    limit: z.number(),
    offset: z.number(),
  });

  const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });

  const idParam = z.object({ id: z.string() });
  const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });

  registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/favorite", summary: "Un-favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate/review a recipe (1-5, optional text + photo)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), photoKey: z.string().max(500).optional(), photoSizeMb: z.number().int().min(0).max(50).default(0) }) } }, required: true } }, responses: { 200: { description: "Rating updated", content: { "application/json": { schema: z.object({ updated: z.literal(true) }) } } }, 201: { description: "Rating created", content: { "application/json": { schema: z.object({ created: z.literal(true) }) } } }, 400: { description: "Validation error or cannot rate your own recipe", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.object({ data: z.array(CommentRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", description: "Rate-limited: 20 req/min.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Recipes: bulk, fork, cooked, nutrition, notes, reviews, versions, comment reactions ---
  const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
  const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", z.object({
    ids: z.array(z.string()).min(1).max(100),
    visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
    addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
    removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
  }));
  const RecipeNoteRef = registry.register("RecipeNote", z.object({ content: z.string(), updatedAt: z.string().datetime() }).nullable());
  const RecipeReviewRef = registry.register("RecipeReview", z.object({
    id: z.string(), score: z.number().int(), reviewText: z.string().nullable(), photoKey: z.string().nullable(),
    createdAt: z.string().datetime(),
    user: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
  }));
  const RecipeVersionSummaryRef = registry.register("RecipeVersionSummary", z.object({
    id: z.string(), version: z.number().int(), title: z.string(), createdAt: z.string().datetime(),
  }));

  registry.registerPath({ method: "delete", path: "/api/v1/recipes/bulk", summary: "Bulk delete recipes (owned only)", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", description: "Gated by the markdown_export tier feature flag.", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/cooked", summary: "List your cook log for this recipe", description: "Plain (non-batch) entries only — batch-cook dishes have their own per-dish cooked indicator.", security, request: { params: idParam }, responses: { 200: { description: "Cook log, newest first", content: { "application/json": { schema: z.object({ data: z.array(z.object({ id: z.string(), cookedAt: z.string().datetime(), servings: z.number().int().nullable(), notes: z.string().nullable() })) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry. A recipe can be logged as cooked any number of times — each call inserts a new history row, never updates one. cookedAt lets you backdate a cook instead of only logging \"now\".", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional(), cookedAt: z.string().optional().describe("ISO date (YYYY-MM-DD) or datetime; defaults to now") }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean(), id: z.string() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/recipes/{id}/cooked/{logId}", summary: "Edit a cook log entry", description: "Plain (non-batch) entries only. Never touches pantry quantities — any deduction from when this was logged is not reversed or reapplied.", security, request: { params: z.object({ id: z.string(), logId: z.string() }), body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).nullable().optional(), notes: z.string().max(2000).nullable().optional(), cookedAt: z.string().optional() }) } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/cooked/{logId}", summary: "Remove a cook log entry", description: "Plain (non-batch) entries only. Never touches pantry quantities.", security, request: { params: z.object({ id: z.string(), logId: z.string() }) }, responses: { 204: { description: "Removed" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate or manually-entered values", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }).nullable(), manual: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual. Gated by the nutrition_estimation tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/regenerate", summary: "Revise a recipe draft per a free-text instruction (recipe editor's \"Regenerate with AI\")", description: "Rate-limited: 10 req/min. Consumes AI quota. Stateless — takes the current draft's fields directly (not a recipeId), so it reflects unsaved editor changes; does not write to the database.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100), difficulty: z.enum(["easy", "medium", "hard"]).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.string(), z.number()]).optional(), unit: z.string().max(50).optional() })).max(100), steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100), instruction: z.string().min(1).max(500), language: z.string().max(10).default("en"), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Revised draft", content: { "application/json": { schema: AiGeneratedRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min. Gated by the recipe_import_url tier feature flag.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- AI (Phase 3): recipe generation, transforms, chat ---
  const AiChatMessageRef = registry.register("AiChatMessage", z.object({
    id: z.string(), role: z.enum(["user", "assistant"]), content: z.string(),
    createdAt: z.string().datetime(), recipeId: z.string().nullable(), recipeTitle: z.string().nullable(),
  }));

  registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Two AI calls internally — a vision model recognizes the photo, then a text model writes the structured recipe — but only counts as one quota unit. Written in the caller's app language, regardless of the language shown in the photo. Gated by the recipe_import_photo tier feature flag.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/drinks/{id}", summary: "Suggest drink pairings for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Drinks", content: { "application/json": { schema: z.object({ drinks: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/pairings/{id}", summary: "Suggest food pairings/side dishes for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Pairings", content: { "application/json": { schema: z.object({ pairings: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/translate/{id}", summary: "Translate a recipe (author only) and save as a new draft", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ targetLanguage: z.string().min(2).max(50), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/generate-meal", summary: "Generate a themed, coherent set of recipes (one per course) and add them to a collection", description: "Rate-limited: 3 req/min. Consumes AI quota (one call covers the whole meal). Charges your tier's recipe limit once per generated recipe (refunded if the limit is exceeded). The collection must be owned by the caller.", security, request: { body: { content: { "application/json": { schema: z.object({ collectionId: z.string(), theme: z.string().min(1).max(200), courses: z.array(z.enum(["starter", "main", "side", "dessert", "drink"])).min(2).max(6), servings: z.number().int().min(1).max(20).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) } }, required: true } }, responses: { 200: { description: "Created recipes, already added to the collection", content: { "application/json": { schema: z.object({ collectionId: z.string(), recipes: z.array(z.object({ id: z.string(), title: z.string(), course: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Collection not found", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({
    title: z.string(), description: z.string().optional(), baseServings: z.number().int(),
    recipeType: z.enum(["dish", "drink"]), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
    prepMins: z.number().int().optional(), cookMins: z.number().int().optional(),
    ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
    steps: z.array(z.object({ instruction: z.string() })),
  }));
  const ProposedShoppingListRef = registry.register("ProposedShoppingList", z.object({
    items: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
    suggestedListName: z.string().optional(),
  }));
  registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe or add items to a shopping list, the response may include proposedRecipe / proposedShoppingList — drafts the assistant proposed but did NOT save; the caller must POST them itself (to /api/v1/recipes, or /api/v1/shopping-lists + /api/v1/shopping-lists/{id}/items) to actually create anything.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional(), proposedShoppingList: ProposedShoppingListRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  const AiConversationRef = registry.register("AiConversation", z.object({
    id: z.string(), title: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
  }));
  registry.registerPath({ method: "get", path: "/api/v1/ai/conversations", summary: "List your general-assistant conversations, most recently active first", security, responses: { 200: { description: "Conversations", content: { "application/json": { schema: z.object({ data: z.array(AiConversationRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/conversations", summary: "Start a new (untitled) general-assistant conversation", security, responses: { 201: { description: "Created", content: { "application/json": { schema: AiConversationRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/ai/conversations/{id}", summary: "Rename a conversation (null clears the title back to auto-generated)", security, request: { params: z.object({ id: z.string() }), body: { content: { "application/json": { schema: z.object({ title: z.string().max(100).nullable() }) } }, required: true } }, responses: { 200: { description: "Renamed", content: { "application/json": { schema: z.object({ id: z.string(), title: z.string().nullable() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/ai/conversations/{id}", summary: "Delete a conversation and its messages", security, request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Deleted", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-chat", summary: "Ask a question about a specific recipe (author only)", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string().uuid(), question: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/ai/chat-history", summary: "List your AI chat history (general assistant and/or per-recipe)", description: "Omit recipeId, conversationId, and scope to search across everything; pass conversationId for one general-assistant conversation, or scope=general for the legacy single general thread.", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), conversationId: z.string().uuid().optional(), scope: z.enum(["general"]).optional(), q: z.string().max(200).optional(), limit: z.coerce.number().int().min(1).max(100).default(30) }) }, responses: { 200: { description: "Messages", content: { "application/json": { schema: z.object({ data: z.array(AiChatMessageRef) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/ai/chat-history", summary: "Clear your AI chat history (general assistant and/or per-recipe)", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), scope: z.enum(["general"]).optional() }) }, responses: { 200: { description: "Cleared", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  const FeedRecipeRef = registry.register("FeedRecipe", z.object({
    id: z.string(), title: z.string(), description: z.string().nullable(),
    baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
    tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
    authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
    photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
    dishCount: z.number().int(), isFavorited: z.boolean(),
  }));
  registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  const CreateCollectionRef = registry.register("CreateCollection", z.object({
    name: z.string().min(1).max(100), description: z.string().max(500).optional(),
    visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
  }));
  registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Collections: single-resource CRUD, fork, favorite, members ---
  const CollectionMemberRef = registry.register("CollectionMember", z.object({
    id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
    user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
  }));
  const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
    name: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(),
    notes: z.string().max(2000).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(),
    visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
    addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(),
    removeRecipeId: z.string().optional(), removeRecipeIds: z.array(z.string()).max(200).optional(),
  }));
  const ReorderCollectionRecipesRef = registry.register("ReorderCollectionRecipes", z.object({
    recipeIds: z.array(z.string()).min(1).max(500).describe("Full ordered list of recipe ids currently in the collection."),
  }));
  const InviteMemberRef = registry.register("InviteMember", z.object({
    email: z.string().email().optional(), userId: z.string().optional(), role: z.enum(["viewer", "editor"]),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/collections/{id}", summary: "Get a collection (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Collection with recipes", content: { "application/json": { schema: CollectionRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/collections/{id}", summary: "Update a collection / add-remove recipes (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateCollectionRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}", summary: "Delete a collection (owner only)", description: "Pass ?deleteRecipes=true to also delete the recipes in it — only ones you own; recipes shared into this collection by others are never touched.", security, request: { params: idParam, query: z.object({ deleteRecipes: z.enum(["true", "false"]).optional() }) }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/collections/{id}/reorder", summary: "Reorder the recipes in a collection (owner only)", description: "Pass the full list of recipe ids in the desired order — ids not currently in the collection are ignored.", security, request: { params: idParam, body: { content: { "application/json": { schema: ReorderCollectionRecipesRef } }, required: true } }, responses: { 200: { description: "Reordered", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error or no matching recipes", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/fork", summary: "Fork a public (or your own) collection", security, request: { params: idParam }, responses: { 201: { description: "New collection id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/favorite", summary: "Favorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/favorite", summary: "Unfavorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/collections/{id}/members", summary: "List collection members (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(CollectionMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/members", summary: "Invite a member by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get your own meal plan for a week", security, request: { params: weekStartParam }, responses: { 200: { description: "Meal plan with entries", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}", summary: "Create the meal plan for a week (idempotent)", security, request: { params: weekStartParam }, responses: { 200: { description: "Already existed", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } }, 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/meal-plans/{weekStart}", summary: "Set whether a week's plan has a public read-only link", description: "Creates the week's plan if it doesn't exist yet. The link is /m/{id} — read-only, unlike shopping lists' public link which can also be made editable.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: z.object({ isPublic: z.boolean() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string(), isPublic: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Meal-plan entries, bulk clear, members, nutrition, .ics export, shared plans ---
  const CreateMealPlanEntryRef = registry.register("CreateMealPlanEntry", z.object({
    day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
    mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
    recipeId: z.string().optional(), batchDishId: z.string().optional(),
    servings: z.number().int().min(1).max(100).default(2), note: z.string().max(500).optional(),
  }));
  const MealPlanEntryLeanRef = registry.register("MealPlanEntryLean", z.object({
    id: z.string(), day: z.string(), mealType: z.string(), servings: z.number(),
    recipe: z.object({ id: z.string(), title: z.string() }).nullable(),
    batchDish: z.object({ id: z.string(), name: z.string() }).nullable().optional(),
    note: z.string().nullable().optional(),
  }));
  const MealPlanMemberRef = registry.register("MealPlanMember", z.object({
    id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
    user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
  }));
  const NutritionTotalsRef = registry.register("NutritionTotals", z.object({ calories: z.number(), protein: z.number(), carbs: z.number(), fat: z.number() }));
  const WeeklyNutritionRef = registry.register("WeeklyNutrition", z.object({
    totals: NutritionTotalsRef, dailyAverage: NutritionTotalsRef,
    byDay: z.record(z.string(), NutritionTotalsRef), plannedDays: z.number().int(), unknownCount: z.number().int(),
    goals: z.object({ caloriesKcal: z.number().nullable(), proteinG: z.number().nullable(), carbsG: z.number().nullable(), fatG: z.number().nullable() }).nullable(),
    coverage: NutritionTotalsRef,
  }));

  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "List entries for a week (lean — polled for live updates)", security, request: { params: weekStartParam }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "Add/replace an entry for a day+mealType slot", description: "Creates the week's plan if it doesn't exist yet. Replaces any existing entry in the same slot.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: CreateMealPlanEntryRef } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe or dish not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/{entryId}", summary: "Delete one entry", security, request: { params: z.object({ weekStart: z.string(), entryId: z.string() }) }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/bulk", summary: "Bulk-delete entries (clear a day or the whole week)", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/members", summary: "List collaborators on a week's plan (owner only)", security, request: { params: weekStartParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(MealPlanMemberRef) } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 400: { description: "Cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", description: "Gated by the weekly_nutrition tier feature flag.", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } }, 400: { description: "Invalid weekStart", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}", summary: "View a meal plan you're a collaborator on", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Plan with entries, your role, and the owner's name", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "List entries on a shared plan (lean — polled for live updates)", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 400: { description: "entryId or ids required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", description: "rawName is resolved against the canonical ingredients table (name or alias, case-insensitive) to link ingredientId when there's a match — used for cross-recipe/pantry matching, not exposed as a settable field here.", security, request: { body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().optional(), notes: z.string().max(500).optional(), aisle: z.string().max(50).optional(), expiresAt: z.string().datetime().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/pantry/{id}", summary: "Update a pantry item", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200).optional(), quantity: z.string().nullable().optional(), unit: z.string().nullable().optional(), notes: z.string().max(500).nullable().optional(), aisle: z.string().max(50).nullable().optional(), expiresAt: z.string().datetime().nullable().optional() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/pantry/{id}", summary: "Delete a pantry item", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/pantry/merge-duplicates", summary: "Merge pantry items that resolve to the same ingredient", description: "One-shot cleanup for items added under different names before ingredient-alias matching existed (e.g. \"sel\"/\"sel fin\"/\"sel de table\"). Groups by resolved ingredient key alone (unit is not part of the grouping — two rows merge even with different or missing quantities/units); quantities are summed only when every row in a group has a parseable quantity AND the same unit, otherwise the first known (quantity, unit) pair is kept rather than guessed. Notes are concatenated, never dropped; expiresAt keeps the soonest date in the group.", security, responses: { 200: { description: "Merge result", content: { "application/json": { schema: z.object({ mergedGroups: z.number().int(), removed: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/pantry/bulk", summary: "Add several pantry items at once, merging quantities into existing matching items", security, request: { body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().max(50).optional() })).min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", description: "Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Users: profile, export, prefs, nutrition, social ---
  const usernameParam = z.object({ username: z.string() });
  const UpdateMeRef = registry.register("UpdateMe", z.object({
    name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(),
    bio: z.string().max(500).nullable().optional(), privateBio: z.string().max(2000).nullable().optional(),
    isPrivate: z.boolean().optional(), username: z.string().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
    avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), avatarSizeMb: z.number().int().min(0).max(50).default(0).describe("Size (MB, rounded up) of the file behind avatarKey, from avatar-presign's response. Ignored unless avatarKey is set."), useGravatar: z.boolean().optional(),
  }));
  const ModelPrefsRef = registry.register("ModelPrefs", z.object({
    id: z.string(), userId: z.string(),
    textProvider: z.string().nullable(), textModel: z.string().nullable(),
    visionProvider: z.string().nullable(), visionModel: z.string().nullable(),
    mealPlanProvider: z.string().nullable(), mealPlanModel: z.string().nullable(),
    updatedAt: z.string().datetime(),
  }));
  const UpdateModelPrefsRef = registry.register("UpdateModelPrefs", z.object({
    textProvider: z.string().nullable().optional(), textModel: z.string().nullable().optional(),
    visionProvider: z.string().nullable().optional(), visionModel: z.string().nullable().optional(),
    mealPlanProvider: z.string().nullable().optional(), mealPlanModel: z.string().nullable().optional(),
  }));
  const NotificationPrefsRef = registry.register("NotificationPrefs", z.object({
    follow: z.boolean(), comment: z.boolean(), reply: z.boolean(), reaction: z.boolean(),
    rating: z.boolean(), mention: z.boolean(), leftoverExpiring: z.boolean(), shoppingList: z.boolean(),
    followEmail: z.boolean(), commentEmail: z.boolean(), replyEmail: z.boolean(), reactionEmail: z.boolean(),
    ratingEmail: z.boolean(), mentionEmail: z.boolean(), leftoverExpiringEmail: z.boolean(), shoppingListEmail: z.boolean(),
    weeklyDigestEmail: z.boolean(),
  }).describe("The non-Email fields gate push notifications; the *Email fields gate the matching email. weeklyDigestEmail gates the weekly digest email (no push equivalent)."));
  const UpdateNotificationPrefsRef = registry.register("UpdateNotificationPrefs", z.object({
    follow: z.boolean().optional(), comment: z.boolean().optional(), reply: z.boolean().optional(), reaction: z.boolean().optional(),
    rating: z.boolean().optional(), mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(),
    followEmail: z.boolean().optional(), commentEmail: z.boolean().optional(), replyEmail: z.boolean().optional(), reactionEmail: z.boolean().optional(),
    ratingEmail: z.boolean().optional(), mentionEmail: z.boolean().optional(), leftoverExpiringEmail: z.boolean().optional(), shoppingListEmail: z.boolean().optional(),
    weeklyDigestEmail: z.boolean().optional(),
  }));
  const FeaturePrefsRef = registry.register("FeaturePrefs", z.object({
    nutrition: z.boolean(), pantry: z.boolean(), mealPlan: z.boolean(),
    shoppingLists: z.boolean(), collections: z.boolean(), messages: z.boolean(), chatbots: z.boolean(),
  }).describe("Purely cosmetic — hides the feature from the user's own nav. Never restricts access to the underlying pages/routes."));
  const UpdateFeaturePrefsRef = registry.register("UpdateFeaturePrefs", z.object({
    nutrition: z.boolean().optional(), pantry: z.boolean().optional(), mealPlan: z.boolean().optional(),
    shoppingLists: z.boolean().optional(), collections: z.boolean().optional(), messages: z.boolean().optional(),
    chatbots: z.boolean().optional(),
  }));

  const NutritionGoalsRef2 = registry.register("NutritionGoalsMe", z.object({
    id: z.string(), userId: z.string(),
    caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(),
    carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
    updatedAt: z.string().datetime(),
  }).nullable());
  const UpdateNutritionGoalsRef = registry.register("UpdateNutritionGoals", z.object({
    caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
    carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
  }));
  const OnboardingStatusRef = registry.register("OnboardingStatus", z.object({
    completed: z.boolean(),
    dietaryTags: z.record(z.string(), z.boolean()),
    allergens: z.array(z.string()),
  }));
  const CompleteOnboardingRef = registry.register("CompleteOnboarding", z.object({
    dietaryTags: z.object({
      vegan: z.boolean().optional(), vegetarian: z.boolean().optional(), glutenFree: z.boolean().optional(),
      dairyFree: z.boolean().optional(), nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(),
    }).optional(),
    allergens: z.array(z.enum(["peanuts", "treeNuts", "dairy", "eggs", "shellfish", "fish", "soy", "gluten"])).optional(),
  }));
  const BillingDetailsRef = registry.register("BillingDetailsMe", z.object({
    id: z.string(), userId: z.string(),
    fullName: z.string().nullable(), addressLine1: z.string().nullable(), addressLine2: z.string().nullable(),
    city: z.string().nullable(), postalCode: z.string().nullable(), country: z.string().nullable(), phone: z.string().nullable(),
    updatedAt: z.string().datetime(),
  }).nullable());
  const UpdateBillingDetailsRef = registry.register("UpdateBillingDetails", z.object({
    fullName: z.string().min(1).max(200), addressLine1: z.string().max(200).optional(), addressLine2: z.string().max(200).optional(),
    city: z.string().max(120).optional(), postalCode: z.string().max(20).optional(), country: z.string().max(120).optional(), phone: z.string().max(40).optional(),
  }));
  const NutritionDiaryEntryRef = registry.register("NutritionDiaryEntry", z.object({
    id: z.string(), recipeId: z.string(), title: z.string(), servings: z.number(),
    cookedAt: z.string().datetime(), nutritionKnown: z.boolean(),
  }));
  const NutritionTotalsExtRef = registry.register("NutritionTotalsExt", z.object({
    calories: z.number(), proteinG: z.number(), carbsG: z.number(), fatG: z.number(), fiberG: z.number(), sodiumMg: z.number(),
  }));
  const NutritionGoalsSummaryRef = registry.register("NutritionGoalsSummary", z.object({
    caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(), carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
  }));
  const NutritionDiaryRef = registry.register("NutritionDiary", z.object({
    date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
    coverage: NutritionTotalsRef, entries: z.array(NutritionDiaryEntryRef), unknownCount: z.number().int(),
  }));
  const NutritionTrendRef = registry.register("NutritionTrend", z.object({
    range: z.number().int(), goalCalories: z.number().int().nullable(),
    days: z.array(z.object({
      date: z.string(), calories: z.number().int(), proteinG: z.number().int(), carbsG: z.number().int(), fatG: z.number().int(),
    })),
  }));
  const UserSearchResultRef = registry.register("UserSearchResult", z.object({
    id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
  }));
  const UserProfilePublicRef = registry.register("UserProfilePublic", z.object({
    id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
    createdAt: z.string().datetime(), followerCount: z.number().int(), followingCount: z.number().int(),
    recipeCount: z.number().int(), isFollowing: z.boolean(),
  }));

  registry.registerPath({ method: "patch", path: "/api/v1/users/me", summary: "Update your profile", description: "Toggling useGravatar or clearing avatarKey re-derives the effective avatar (Gravatar or initials fallback).", security, request: { body: { content: { "application/json": { schema: UpdateMeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), avatarUrl: z.string().nullable() }) } } }, 400: { description: "Validation error, or avatarKey wasn't issued to you by avatar-presign", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Username already taken", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/export", summary: "Export all of your account data as a JSON file", description: "Rate-limited: 3 req/hour. Includes profile, recipes, social graph, collections, meal plans, pantry, shopping lists, webhooks, API keys, messages, and more.", security, responses: { 200: { description: "Downloadable JSON export", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/model-prefs", summary: "Get your AI model provider/model preferences", description: "Requires BYOK access (an admin must enable it for your account) — picking a specific provider/model only makes sense once you've got your own key to route it to.", security, responses: { 200: { description: "Preferences or null", content: { "application/json": { schema: ModelPrefsRef.nullable() } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", description: "Requires BYOK access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: UpdateModelPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/notification-prefs", summary: "Get your notification category preferences", description: "Categories with no saved row default to enabled.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: NotificationPrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/users/me/developer-access", summary: "Self-serve enable or disable developer access", description: "Enabling requires a paid tier (free at no extra charge) — free-tier users need an admin grant instead. Disabling is always allowed, regardless of tier or who originally granted it. Only covers webhooks/API keys — BYOK is a separate, admin-only permission.", security, request: { body: { content: { "application/json": { schema: z.object({ enabled: z.boolean() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), isDeveloper: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Free tier — upgrade or ask an admin", content: { "application/json": { schema: ApiErrorRef } } } } });
  const BillingStatusRef = registry.register("BillingStatus", z.object({
    tier: z.enum(["free", "pro", "family"]), subscriptionStatus: z.enum(["active", "trialing", "past_due", "canceled", "incomplete"]).nullable(),
    currentPeriodEnd: z.string().datetime().nullable(), hasStripeCustomer: z.boolean(),
  }));
  registry.registerPath({ method: "get", path: "/api/v1/billing/status", summary: "Get your current tier, subscription status, and renewal date", security, responses: { 200: { description: "Status", content: { "application/json": { schema: BillingStatusRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/billing/checkout", summary: "Start a Stripe Checkout session to subscribe or switch plans", description: "Rate-limited: 10 req/min. Redirects to the returned url. Promotion codes are enabled on the Checkout page.", security, request: { body: { content: { "application/json": { schema: z.object({ tier: z.enum(["pro", "family"]), interval: z.enum(["month", "year"]) }) } }, required: true } }, responses: { 200: { description: "Checkout URL", content: { "application/json": { schema: z.object({ url: z.string() }) } } }, 400: { description: "Billing not configured, or no price set for that tier/interval", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/billing/portal", summary: "Open the Stripe Customer Portal to manage or cancel your subscription", description: "Rate-limited: 10 req/min. Requires having been through Checkout at least once.", security, responses: { 200: { description: "Portal URL", content: { "application/json": { schema: z.object({ url: z.string() }) } } }, 400: { description: "Billing not configured, or no billing account yet", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/billing-details", summary: "Get your billing/invoice details", description: "Full name, address, and phone used on invoices — separate from your display name.", security, responses: { 200: { description: "Billing details or null", content: { "application/json": { schema: z.object({ data: BillingDetailsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/users/me/billing-details", summary: "Set your billing/invoice details", description: "fullName is required; addressLine1/2, city, postalCode, country, and phone are all optional.", security, request: { body: { content: { "application/json": { schema: UpdateBillingDetailsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/users/me/upgrade-interest", summary: "Record interest in a locked feature", description: "Fired when you click \"See Pro plans\" on a locked-feature upgrade prompt — a signal for admin insights (most-requested locked features), not a purchase. Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ featureKey: z.string().min(1).max(100) }) } }, required: true } }, responses: { 201: { description: "Recorded", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/me/onboarding", summary: "Get your post-signup onboarding status", security, responses: { 200: { description: "Status", content: { "application/json": { schema: z.object({ data: OnboardingStatusRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/users/me/onboarding", summary: "Complete (or skip) the post-signup onboarding wizard", description: "Marks onboarding done regardless of what's in the body — call with an empty body to skip entirely. dietaryTags/allergens are only written when provided.", security, request: { body: { content: { "application/json": { schema: CompleteOnboardingRef } } } }, responses: { 200: { description: "Completed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/follow", summary: "Unfollow a user", security, request: { params: usernameParam }, responses: { 200: { description: "Not following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/users/{username}/block", summary: "Block a user", description: "Also removes any existing follow relationship in either direction.", security, request: { params: usernameParam }, responses: { 200: { description: "Blocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 400: { description: "Cannot block yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/block", summary: "Unblock a user", security, request: { params: usernameParam }, responses: { 200: { description: "Unblocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", description: "Returns all of your lists, unpaginated.", security, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", description: "Optionally seeded from a meal-plan week's merged ingredients (pantry-aware).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).optional(), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Shopping lists: single-resource CRUD, items, members, export ---
  const ShoppingListItemRef = registry.register("ShoppingListItem", z.object({
    id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(),
    aisle: z.string().nullable(), checked: z.boolean(), sortOrder: z.number().int(),
  }));
  const UpdateShoppingListRef = registry.register("UpdateShoppingList", z.object({
    completed: z.boolean().optional(), name: z.string().min(1).max(100).optional(),
    isPublic: z.boolean().optional(), publicEditable: z.boolean().optional(),
  }));
  const AddShoppingListItemsRef = registry.register("AddShoppingListItems", z.object({
    items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).min(1),
  }));
  const UpdateShoppingListItemRef = registry.register("UpdateShoppingListItem", z.object({
    checked: z.boolean().optional(), rawName: z.string().min(1).optional(), quantity: z.string().nullable().optional(),
    unit: z.string().nullable().optional(), aisle: z.string().nullable().optional(), sortOrder: z.number().int().optional(),
  }));
  const ShoppingListMemberRef = registry.register("ShoppingListMember", z.object({
    id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
    user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
  }));
  const GroceryExportRef = registry.register("GroceryExport", z.object({
    listName: z.string(),
    items: z.array(z.object({ name: z.string(), quantity: z.string().nullable(), unit: z.string().nullable() })),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}", summary: "Get a shopping list with items", security, request: { params: idParam }, responses: { 200: { description: "List", content: { "application/json": { schema: ShoppingListRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}", summary: "Rename, mark complete, or toggle public link/public-editable", description: "Renaming, deleting, and toggling the public link are owner-only. Turning off the public link also revokes public editing.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateShoppingListRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}", summary: "Delete a shopping list (owner only)", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/items", summary: "List items (lean — polled for live updates)", description: "No auth required if the list is public-editable — access is the link (id) itself.", security, request: { params: idParam }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ items: z.array(ShoppingListItemRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/items", summary: "Add items", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: AddShoppingListItemsRef } }, required: true } }, responses: { 201: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}/items", summary: "Bulk update sortOrder/aisle (e.g. after drag-and-drop reorder)", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ id: z.string().min(1), sortOrder: z.number().int().optional(), aisle: z.string().nullable().optional() })).min(1) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Update one item (check/uncheck, rename, re-quantify, move aisle)", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }), body: { content: { "application/json": { schema: UpdateShoppingListItemRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Delete one item", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }) }, responses: { 200: { description: "Deleted", content: { "application/json": { schema: z.object({ deleted: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/members", summary: "List collaborators (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(ShoppingListMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/members", summary: "Invite a collaborator by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side. Gated by the grocery_delivery tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", description: "Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required, or API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- AI keys: users' own third-party provider keys (BYOK), used instead of app-wide AI credentials ---
  const AiKeyRef = registry.register("AiKey", z.object({
    provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), createdAt: z.string().datetime(),
  }).describe("Never includes the encrypted key value itself — only which providers are configured."));

  registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API. Requires BYOK access (an admin must enable it for your account) — separate from developer access, which only covers webhooks/API keys.", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response. Requires BYOK access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", description: "Requires BYOK access (an admin must enable it for your account).", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Webhooks: outbound event notifications to a user-provided URL, plus delivery log/redelivery ---
  const WebhookEventEnum = z.enum(["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added"]);
  const WebhookRef = registry.register("Webhook", z.object({
    id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
  }));
  const CreateWebhookRef = registry.register("CreateWebhook", z.object({
    url: z.string().min(1).max(2048), events: z.array(WebhookEventEnum).default([]),
  }));
  const CreatedWebhookRef = registry.register("CreatedWebhook", z.object({
    id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum),
    secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
    active: z.boolean(), createdAt: z.string().datetime(),
  }));
  const UpdateWebhookRef = registry.register("UpdateWebhook", z.object({
    url: z.string().min(1).max(2048).optional(), events: z.array(WebhookEventEnum).optional(), active: z.boolean().optional(),
  }));
  const WebhookDeliveryRef = registry.register("WebhookDelivery", z.object({
    id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
    statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/webhooks", summary: "List your webhooks", description: "The signing secret is never included — it is only ever returned once, at creation time. Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(WebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/webhooks", summary: "Create a webhook", description: "Validates the URL against SSRF targets. Generates a signing secret returned once in this response only. Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: CreateWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/webhooks/{id}", summary: "Update a webhook's URL, events, or active state", description: "Validates the URL against SSRF targets if changed. Requires developer access (an admin must enable it for your account).", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: WebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/webhooks/{id}", summary: "Delete a webhook", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  const AdminWebhookEventEnum = z.enum(["user.signed_up", "support_ticket.created", "report.filed"]);
  const AdminWebhookRef = registry.register("AdminWebhook", z.object({
    id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
  }));
  const CreateAdminWebhookRef = registry.register("CreateAdminWebhook", z.object({
    url: z.string().min(1).max(2048), events: z.array(AdminWebhookEventEnum).default([]),
  }));
  const CreatedAdminWebhookRef = registry.register("CreatedAdminWebhook", z.object({
    id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum),
    secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
    active: z.boolean(), createdAt: z.string().datetime(),
  }));
  const UpdateAdminWebhookRef = registry.register("UpdateAdminWebhook", z.object({
    url: z.string().min(1).max(2048).optional(), events: z.array(AdminWebhookEventEnum).optional(), active: z.boolean().optional(),
  }));
  const AdminWebhookDeliveryRef = registry.register("AdminWebhookDelivery", z.object({
    id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
    statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
  }));

  // --- Support: bug reports, suggestions, and questions submitted in-app ---
  const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
  const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
  const SupportAttachmentRef = registry.register("SupportAttachment", z.object({
    id: z.string(), contentType: z.string(), url: z.string(),
  }));
  const SupportTicketRef = registry.register("SupportTicket", z.object({
    id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
    status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaIssueNumber: z.number().nullable().optional(),
    giteaError: z.string().nullable(),
    attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
  }));
  const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
    type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000),
    attachments: z.array(z.object({
      key: z.string().describe("Storage key returned by POST /api/v1/support/attachments/presign"),
      contentType: z.string(), sizeBytes: z.number().int().positive(),
    })).max(5).default([]),
  }));
  const PresignSupportAttachmentRef = registry.register("PresignSupportAttachment", z.object({
    contentType: z.string(), fileSize: z.number().int().positive().max(10 * 1024 * 1024),
  }));
  const PresignedPostRef = registry.register("PresignedSupportPost", z.object({
    url: z.string(), fields: z.record(z.string(), z.string()), key: z.string(),
    contentType: z.string(), fileSize: z.number().int(),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/support", summary: "List your support tickets", security, responses: { 200: { description: "Tickets, newest first", content: { "application/json": { schema: z.array(SupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email. Attach files by presigning each one first via POST /api/v1/support/attachments/presign, uploading to the returned URL, then passing the keys here.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/support/attachments/presign", summary: "Get a presigned upload URL for a support ticket attachment", description: "Rate-limited: 20 req/hour. Accepts images, PDF, plain text, JSON, or zip, up to 10MB. Upload the file as multipart form data to the returned url+fields, then include the returned key when submitting the ticket.", security, request: { body: { content: { "application/json": { schema: PresignSupportAttachmentRef } }, required: true } }, responses: { 200: { description: "Presigned upload", content: { "application/json": { schema: PresignedPostRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });

  const SupportTicketCommentRef = registry.register("SupportTicketComment", z.object({
    id: z.string(), ticketId: z.string(), authorType: z.enum(["user", "admin", "gitea"]),
    authorId: z.string().nullable(), body: z.string(), createdAt: z.string().datetime(),
  }));
  const CreateSupportTicketCommentRef = registry.register("CreateSupportTicketComment", z.object({
    body: z.string().min(1).max(3000),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/support/{id}/comments", summary: "List a ticket's conversation", description: "Owner only. Includes replies posted from Epicure and, if the ticket has a linked Gitea issue, comments made directly on that issue (author type \"gitea\").", security, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/support/{id}/comments", summary: "Reply to your own support ticket", description: "Owner only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  const AdminSupportTicketRef = registry.register("AdminSupportTicket", z.object({
    id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(),
    type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
    status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(),
    createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
    attachments: z.array(z.object({ id: z.string(), contentType: z.string(), storageKey: z.string(), url: z.string() })),
  }));
  const UpdateAdminSupportTicketRef = registry.register("UpdateAdminSupportTicket", z.object({
    status: SupportTicketStatusEnum.optional(),
    retryGitea: z.boolean().optional().describe("Re-attempts opening a Gitea issue for this ticket (e.g. after a prior giteaError)."),
  }));

  // --- Conversations: 1:1 direct messages ---
  const ConversationSummaryRef = registry.register("ConversationSummary", z.object({
    id: z.string(),
    otherUser: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }).nullable(),
    lastMessage: z.string().nullable(), lastMessageAt: z.string().datetime(), unreadCount: z.number(),
  }));
  const StartConversationRef = registry.register("StartConversation", z.object({
    username: z.string().min(1), content: z.string().min(1).max(4000).optional(),
  }));
  const MessageRef = registry.register("Message", z.object({
    id: z.string(), conversationId: z.string(), senderId: z.string(), content: z.string(), createdAt: z.string().datetime(),
  }));
  const SendMessageRef = registry.register("SendMessage", z.object({ content: z.string().min(1).max(4000) }));

  registry.registerPath({ method: "get", path: "/api/v1/conversations", summary: "List your conversations, most recently active first", description: "Up to 50 conversations.", security, responses: { 200: { description: "Conversations", content: { "application/json": { schema: z.object({ conversations: z.array(ConversationSummaryRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/conversations", summary: "Start (or reuse) a conversation with a user by username, optionally sending a first message", description: "Rate-limited: 30 req/min. Fails (404) if either side has blocked the other, target doesn't exist, or you target yourself.", security, request: { body: { content: { "application/json": { schema: StartConversationRef } }, required: true } }, responses: { 201: { description: "Conversation id", content: { "application/json": { schema: z.object({ conversationId: z.string() }) } } }, 400: { description: "Validation error or cannot message yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or blocked", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/conversations/{id}/messages", summary: "List messages in a conversation (cursor-paginated, oldest-first per page)", description: "Loading the newest page (no `before`) also marks the conversation read.", security, request: { params: idParam, query: z.object({ before: z.string().datetime().optional().describe("ISO createdAt cursor — returns the 50 messages just before it") }) }, responses: { 200: { description: "Page of messages", content: { "application/json": { schema: z.object({ messages: z.array(MessageRef), nextCursor: z.string().nullable() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/conversations/{id}/messages", summary: "Send a message in a conversation", description: "Rate-limited: 30 req/min. Fails (404) if the other participant has blocked you or vice versa.", security, request: { params: idParam, body: { content: { "application/json": { schema: SendMessageRef } }, required: true } }, responses: { 201: { description: "Message id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Notifications ---
  const NotificationRef = registry.register("Notification", z.object({
    id: z.string(), type: z.enum(["follow", "comment", "reply", "reaction", "rating", "mention"]),
    recipeId: z.string().nullable(), commentId: z.string().nullable(), read: z.boolean(), createdAt: z.string().datetime(),
    actorId: z.string(), actorName: z.string(), actorUsername: z.string().nullable(), actorAvatarUrl: z.string().nullable(),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/notifications", summary: "List your notifications, newest first", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated notifications", content: { "application/json": { schema: z.object({ notifications: z.array(NotificationRef), unreadCount: z.number(), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/notifications/{id}/read", summary: "Mark a single notification read", security, request: { params: idParam }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/notifications/read", summary: "Mark notifications read — one by id, or all of yours if omitted", security, request: { body: { content: { "application/json": { schema: z.object({ id: z.string().optional() }) } } } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Feed: trending & personalized ---
  const TrendingRecipeRef = registry.register("TrendingRecipe", z.object({
    id: z.string(), title: z.string(), description: z.string().nullable(),
    baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
    aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
    authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
    favoriteCount: z.number().int(),
  }));
  const ForYouRecipeRef = registry.register("ForYouRecipe", z.object({
    id: z.string(), title: z.string(), description: z.string().nullable(),
    baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
    aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
    authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
    tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
    photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
    dishCount: z.number().int(), isFavorited: z.boolean(),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/feed/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", security: [], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/feed/for-you", summary: "Personalized recipe feed, ranked from your favorites and 4+ ratings", description: "Scores a bounded window of the 200 most recent eligible candidates rather than the whole table, so `total` reflects that window, not every eligible recipe.", security, request: { query: z.object({ limit: z.coerce.number().int().max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Ranked, paginated", content: { "application/json": { schema: z.object({ data: z.array(ForYouRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Search ---
  const SearchResultRef = registry.register("SearchResult", z.object({
    id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
    baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
    visibility: z.enum(["private", "unlisted", "public", "followers"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
    sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
    authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
    avgRating: z.number().nullable(), ratingCount: z.number().int(),
    photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
    dishCount: z.number().int(), isFavorited: z.boolean(),
  }));

  registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), recipeType: z.enum(["dish", "drink"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Invites ---
  registry.registerPath({ method: "get", path: "/api/v1/invites/{token}", summary: "Check whether an invite token is valid", description: "Public — no auth required.", security: [], request: { params: z.object({ token: z.string() }) }, responses: { 200: { description: "Validity (and the invited email, if valid)", content: { "application/json": { schema: z.object({ valid: z.boolean(), email: z.string().optional() }) } } } } });

  // --- Uploads: S3 presigned URLs ---
  const PresignUploadRef = registry.register("PresignUpload", z.object({
    contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
    recipeId: z.string().uuid(), purpose: z.enum(["recipe", "review"]).default("recipe"),
    fileSize: z.number().int().positive().max(10 * 1024 * 1024, "File exceeds 10MB limit"),
  }));
  const PresignAvatarUploadRef = registry.register("PresignAvatarUpload", z.object({
    contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
    fileSize: z.number().int().positive().max(5 * 1024 * 1024, "File exceeds 5MB limit"),
  }));
  const PresignResponseRef = registry.register("PresignResponse", z.object({
    url: z.string().describe("POST this URL as multipart/form-data — include every field below, then the file itself last."),
    fields: z.record(z.string(), z.string()).describe("Form fields to include in the upload POST, including a signed content-length-range condition that the storage server enforces (the declared fileSize can't be lied about)."),
    key: z.string(),
  }));

  registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 upload POST (URL + form fields) for a recipe or review photo", description: "Recipe photos: recipeId must be your own recipe. Review photos: recipe must be visible to you. Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/upload/avatar-presign", summary: "Get a presigned S3 upload POST (URL + form fields) for your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Push notifications ---
  const PushSubscribeRef = registry.register("PushSubscribe", z.object({
    endpoint: z.string().url(), keys: z.object({ p256dh: z.string(), auth: z.string() }),
  }));
  const PushUnsubscribeRef = registry.register("PushUnsubscribe", z.object({ endpoint: z.string() }));

  registry.registerPath({ method: "post", path: "/api/v1/push/subscribe", summary: "Register (or update) a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushSubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/push/subscribe", summary: "Remove a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushUnsubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Reports ---
  const CreateReportRef = registry.register("CreateReport", z.object({
    targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string().min(1), reason: z.string().min(1).max(1000),
  }));

  registry.registerPath({ method: "post", path: "/api/v1/reports", summary: "Report a recipe, comment, or user for moderation", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: CreateReportRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Target not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Comments: edit & delete ---
  registry.registerPath({ method: "put", path: "/api/v1/comments/{id}", summary: "Edit your own comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000) }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or not yours", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/comments/{id}", summary: "Delete a comment (author, or an admin/moderator)", description: "Non-author deletes re-check the caller's role against the DB rather than the (up to 5-minute-stale) session cookie cache.", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  // --- Admin (Admin only. Session-cookie auth only — requireAdmin() has no API-key support) ---
  const adminSecurity: Array<Record<string, string[]>> = [{ SessionCookie: [] }];

  const InviteRef = registry.register("Invite", z.object({
    id: z.string(), token: z.string(), email: z.string().nullable(),
    role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro", "family"]),
    createdById: z.string(), createdAt: z.string().datetime(),
    expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
    usedById: z.string().nullable(),
  }));
  const CreateInviteRef = registry.register("CreateInvite", z.object({
    email: z.string().optional(), role: z.enum(["user", "moderator", "admin"]).default("user"),
    tier: z.enum(["free", "pro", "family"]).default("free"), expiresInDays: z.number().default(7),
  }));

  const AdminReportRef = registry.register("AdminReport", z.object({
    id: z.string(), targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string(),
    reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
    createdAt: z.string().datetime(), reporterId: z.string(),
    reporterName: z.string().nullable(), reporterEmail: z.string(),
  }));
  const ResolveReportBodyRef = registry.register("ResolveReportBody", z.object({
    status: z.enum(["reviewed", "dismissed"]),
  }));
  const ResolvedReportRef = registry.register("ResolvedReport", z.object({
    id: z.string(), reporterId: z.string(), targetType: z.enum(["recipe", "comment", "user"]),
    targetId: z.string(), reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
    createdAt: z.string().datetime(), reviewedById: z.string().nullable(), reviewedAt: z.string().datetime().nullable(),
  }));

  const UpdateSiteSettingsRef = registry.register("UpdateSiteSettings", z.object({
    OPENAI_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
    ANTHROPIC_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
    OPENROUTER_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
    OPENROUTER_DEFAULT_MODEL: z.string().nullable().optional(),
    OLLAMA_BASE_URL: z.string().nullable().optional(),
    NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().nullable().optional(),
    VAPID_PRIVATE_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
    SIGNUPS_DISABLED: z.string().nullable().optional(),
    DEFAULT_TEXT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for recipe generation and other text use cases when a user has no personal model preference and no BYOK key."),
    DEFAULT_TEXT_MODEL: z.string().nullable().optional(),
    DEFAULT_CHAT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for the chatbot (general cooking assistant + per-recipe Q&A)."),
    DEFAULT_CHAT_MODEL: z.string().nullable().optional(),
    DEFAULT_VISION_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for photo/vision use cases (pantry scan, photo import)."),
    DEFAULT_VISION_MODEL: z.string().nullable().optional(),
    DEFAULT_MEAL_PLAN_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for AI meal-plan generation."),
    DEFAULT_MEAL_PLAN_MODEL: z.string().nullable().optional(),
    AI_TOOL_CALLING_ENABLED: z.string().nullable().optional().describe("\"false\" disables the chatbot's createRecipe/addToShoppingList tools (e.g. for a model that can't reliably call tools); any other value or unset means enabled."),
  }).describe("Unknown keys are silently ignored. Setting a key to null or \"\" deletes it (falls back to env var)."));

  const TestEmailBodyRef = registry.register("TestEmailBody", z.object({ to: z.string().min(1) }));

  const UpdateTierDefinitionBodyRef = registry.register("UpdateTierDefinitionBody", z.object({
    maxRecipes: z.number().int().optional(), aiCallsPerMonth: z.number().int().optional(),
    storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
    stripeProductId: z.string().nullable().optional(), stripePriceIdMonthly: z.string().nullable().optional(),
    stripePriceIdYearly: z.string().nullable().optional(),
  }).describe("Numeric fields must be a non-negative integer, or -1 for unlimited. Stripe fields map a checkout Price back to this tier — null clears."));
  const TierDefinitionRef = registry.register("TierDefinition", z.object({
    tier: z.enum(["free", "pro", "family"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
    storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
    stripeProductId: z.string().nullable(), stripePriceIdMonthly: z.string().nullable(), stripePriceIdYearly: z.string().nullable(),
  }));

  const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
    email: z.string(), name: z.string(), role: z.enum(["user", "moderator", "admin"]).default("user"),
    tier: z.enum(["free", "pro", "family"]).default("free"),
  }));
  const AdminCreatedUserRef = registry.register("AdminCreatedUser", z.object({
    user: z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), tier: z.string() }),
  }));

  const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
    role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "family"]).optional(),
    isDeveloper: z.boolean().optional().describe("Gates webhooks and self-serve API keys. Paid-tier users can also self-enable this (PATCH /api/v1/users/me/developer-access)."),
    isByokEnabled: z.boolean().optional().describe("Gates BYOK AI provider keys. Admin-only, no self-serve path."),
  }));
  const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
    user: z.object({ id: z.string(), role: z.string(), tier: z.string(), isDeveloper: z.boolean(), isByokEnabled: z.boolean() }),
  }));

  const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
    id: z.string().optional(), userId: z.string(), month: z.string(),
    aiCallsUsed: z.number().int(),
  }));

  const tierParam = z.object({ tier: z.enum(["free", "pro", "family"]) });

  registry.registerPath({ method: "get", path: "/api/v1/admin/invites", summary: "List invites", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Invites", content: { "application/json": { schema: z.object({ invites: z.array(InviteRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/admin/invites/{id}", summary: "Revoke an invite", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Revoked", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Invite not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin or moderator.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });

  const UnpublishRecipeRef = registry.register("UnpublishRecipe", z.object({ action: z.literal("unpublish") }));
  registry.registerPath({ method: "patch", path: "/api/v1/admin/recipes/{id}", summary: "Unpublish a public recipe (moderation takedown)", description: "Admin or moderator. Flips visibility to private — the only transition this endpoint allows.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UnpublishRecipeRef } }, required: true } }, responses: { 200: { description: "Unpublished", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only. A status change to/from \"closed\" also best-effort closes/reopens the linked Gitea issue.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/admin/support/{id}/comments", summary: "List a support ticket's conversation", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/admin/support/{id}/comments", summary: "Reply to a support ticket as an admin", description: "Admin only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks", summary: "List site-wide ops webhooks", description: "Admin only. Site-wide events (new signups, support tickets, reports) — distinct from the per-user webhooks under /api/v1/webhooks. The signing secret is never included — it is only ever returned once, at creation time.", security: adminSecurity, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(AdminWebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks", summary: "Create a site-wide ops webhook", description: "Admin only. Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateAdminWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedAdminWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/webhooks/{id}", summary: "Update a site-wide ops webhook's URL, events, or active state", description: "Admin only. Validates the URL against SSRF targets if changed.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminWebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/admin/webhooks/{id}", summary: "Delete a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks/{id}/deliveries", summary: "List recent delivery attempts for a site-wide ops webhook (most recent 20)", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(AdminWebhookDeliveryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload for a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin or moderator.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "post", path: "/api/v1/admin/test-email", summary: "Send a test verification-style email to a given address", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: TestEmailBodyRef } }, required: true } }, responses: { 200: { description: "Sent", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Missing 'to'", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "BETTER_AUTH_URL not configured, or send failed", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "patch", path: "/api/v1/admin/tiers/{tier}", summary: "Update numeric limits for a tier definition", description: "Admin only.", security: adminSecurity, request: { params: tierParam, body: { content: { "application/json": { schema: UpdateTierDefinitionBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ tierDefinition: TierDefinitionRef }) } } }, 400: { description: "Invalid tier or field value", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Tier not found", content: { "application/json": { schema: ApiErrorRef } } } } });

  const FeatureFlagMatrixRef = registry.register("FeatureFlagMatrix", z.record(
    z.string(),
    z.object({ free: z.boolean(), pro: z.boolean(), family: z.boolean() })
  ).describe("Feature key -> per-tier enabled state. A feature/tier pair defaults to enabled=true until explicitly disabled."));
  const UpdateFeatureFlagRef = registry.register("UpdateFeatureFlag", z.object({
    featureKey: z.enum(["recipe_variations", "drink_pairing", "meal_pairing"]),
    tier: z.enum(["free", "pro", "family"]),
    enabled: z.boolean(),
  }));

  const IngredientRef = registry.register("Ingredient", z.object({
    id: z.string(), name: z.string(), aliases: z.array(z.string()), category: z.string().nullable(),
  }));
  const UpsertIngredientRef = registry.register("UpsertIngredient", z.object({
    name: z.string().min(1).max(100), aliases: z.array(z.string().min(1).max(100)).max(50).default([]), category: z.string().max(50).optional(),
  }));
  registry.registerPath({ method: "get", path: "/api/v1/admin/ingredients", summary: "List canonical ingredients", description: "Admin only. Used for name/alias matching (\"sel\"/\"sel fin\"/\"table salt\" recognized as the same ingredient) across pantry, can-cook scoring, auto-deduct, and shopping-list generation.", security: adminSecurity, responses: { 200: { description: "Ingredients", content: { "application/json": { schema: z.object({ data: z.array(IngredientRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "post", path: "/api/v1/admin/ingredients", summary: "Create a canonical ingredient", description: "Admin only. name must be unique.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpsertIngredientRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "put", path: "/api/v1/admin/ingredients/{id}", summary: "Update a canonical ingredient", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpsertIngredientRef.partial() } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Name already exists", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "delete", path: "/api/v1/admin/ingredients/{id}", summary: "Delete a canonical ingredient", description: "Admin only. Unlinks (doesn't cascade-delete) any pantry item or recipe ingredient that referenced it.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });

  registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role, tier, and/or developer access", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
  registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's AI call count for the current month", description: "Admin only. Recipe count and storage are lifetime totals derived from real data, not resettable counters.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });

  const generator = new OpenApiGeneratorV31(registry.definitions);
  cachedSpec = generator.generateDocument({
    openapi: "3.1.0",
    info: { title: "Epicure API", version: "1.0.0", description: "Auth: session cookie or Bearer API key (`ek_...`)." },
    servers: [{ url: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000" }],
  });

  return cachedSpec;
}
