// Hand-authored OpenAPI 3.0 spec for every API surface (including admin), served at
// /api/openapi.json and rendered by the self-hosted Swagger UI at /api-docs. Kept in one
// place, built with small helpers so route coverage stays readable — update this file
// whenever a route is added or its shape changes.

const ok = (description, schema) => ({
  description,
  content: schema ? { "application/json": { schema } } : undefined,
});
const errorResponse = {
  type: "object",
  properties: { ok: { type: "boolean", example: false }, code: { type: "string" } },
};
const err = (description) => ok(description, errorResponse);
const jsonBody = (schema, required = true) => ({
  required,
  content: { "application/json": { schema } },
});
const idParam = {
  name: "id",
  in: "path",
  required: true,
  schema: { type: "string", format: "uuid" },
};
const obj = (properties, required) => ({ type: "object", properties, ...(required ? { required } : {}) });
const str = { type: "string" };
const num = { type: "number", nullable: true };
const bool = { type: "boolean" };
const arr = (items) => ({ type: "array", items });

const bean = obj({
  id: str, name: str, roaster: str, origin: str, process: str, variety: str,
  roastLevel: str, roastDate: { ...str, nullable: true }, initialWeightG: num,
  remainingWeightG: num, url: str, tastingNotes: str, notes: str,
  roastPlanId: { ...str, nullable: true }, archived: bool,
});
const beanBody = obj({
  name: str, roaster: str, origin: str, process: str, variety: str, roastLevel: str,
  roastDate: { ...str, description: "YYYY-MM-DD", nullable: true }, initialWeightG: num,
  url: str, tastingNotes: str, notes: str, roastPlanId: { ...str, nullable: true },
  archived: bool,
});
const brew = obj({
  id: str, beanId: { ...str, nullable: true }, beanName: { ...str, nullable: true },
  method: str, doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str,
  waterTempC: num, brewTimeS: { type: "integer", nullable: true },
  bloomTimeS: { type: "integer", nullable: true }, rating: num, recipe: str,
  tastingNotes: str, notes: str, brewedAt: str,
});
const brewBody = obj({
  beanId: { ...str, nullable: true },
  method: { ...str, description: "One of the brew-method keys from GET /api/brew-methods" },
  doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str, waterTempC: num,
  brewTimeS: { type: "integer", nullable: true }, bloomTimeS: { type: "integer", nullable: true },
  rating: { ...num, description: "0-10" },
  recipe: { ...str, description: "Free-text steps (bloom, pours, timings)" },
  tastingNotes: str, notes: str,
});
const lot = obj({
  id: str, origin: str, variety: str, process: str, producer: str,
  purchaseDate: { ...str, nullable: true }, initialWeightG: { type: "number" },
  remainingWeightG: { type: "number" }, costTotal: num, moisturePct: num, densityGL: num,
  notes: str, archived: bool,
});
const roast = obj({
  id: str, roastPlanId: { ...str, nullable: true }, planTitle: { ...str, nullable: true },
  filename: str, roast: { type: "object", nullable: true }, derived: { type: "object", nullable: true },
  evaluationStatus: { ...str, enum: ["pending", "done", "failed"] },
  evaluationError: { ...str, nullable: true }, evaluationGrade: { ...str, nullable: true },
  evaluationSummary: { ...str, nullable: true },
});

/** @param {{origin?: string}} options */
export function buildOpenApiSpec({ origin = "" } = {}) {
  const security = [{ cookieAuth: [] }, { bearerAuth: [] }];
  const paths = {
    // ── Auth ──
    "/api/auth/signup-enabled": { get: { tags: ["auth"], summary: "Whether self-signup is enabled", security: [], responses: { 200: ok("Flag", obj({ ok: bool, enabled: bool })) } } },
    "/api/auth/signup": { post: { tags: ["auth"], summary: "Create an account (when enabled)", security: [], requestBody: jsonBody(obj({ email: str, password: { ...str, minLength: 12 } }, ["email", "password"])), responses: { 201: ok("Signed up; session cookie set"), 400: err("Invalid credentials"), 403: err("Signups disabled"), 409: err("Email exists") } } },
    "/api/auth/login": { post: { tags: ["auth"], summary: "Log in", security: [], requestBody: jsonBody(obj({ email: str, password: str }, ["email", "password"])), responses: { 200: ok("Logged in; session cookie set"), 401: err("Invalid credentials"), 429: err("Too many attempts") } } },
    "/api/auth/logout": { post: { tags: ["auth"], summary: "Log out the current session", responses: { 200: ok("Logged out") } } },
    "/api/auth/me": { get: { tags: ["auth"], summary: "Current user", responses: { 200: ok("User", obj({ ok: bool, user: obj({ id: str, email: str, role: str }) })), 401: err("Unauthorized") } } },
    "/api/auth/forgot": { post: { tags: ["auth"], summary: "Request a password reset", security: [], requestBody: jsonBody(obj({ email: str }, ["email"])), responses: { 200: ok("Always ok") } } },
    "/api/auth/reset": { post: { tags: ["auth"], summary: "Reset password with a token", security: [], requestBody: jsonBody(obj({ token: str, password: str }, ["token", "password"])), responses: { 200: ok("Password reset"), 400: err("Invalid token/password") } } },

    // ── API tokens ──
    "/api/tokens": {
      get: { tags: ["tokens"], summary: "List your API tokens", responses: { 200: ok("Tokens", obj({ ok: bool, tokens: arr(obj({ id: str, name: str, createdAt: str, lastUsedAt: { ...str, nullable: true } })) })) } },
      post: { tags: ["tokens"], summary: "Create an API token (raw value returned exactly once)", requestBody: jsonBody(obj({ name: str }), false), responses: { 201: ok("Token created", obj({ ok: bool, token: { ...str, description: "rpt_… bearer token — store it now, it is never shown again" }, id: str, name: str })), 409: err("Too many tokens") } },
    },
    "/api/tokens/{id}": { delete: { tags: ["tokens"], summary: "Revoke an API token", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked"), 404: err("Not found") } } },

    // ── Roast plans ──
    "/api/plans": {
      get: { tags: ["roast plans"], summary: "List your roast plans", responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
      post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" } }, ["plan"])), responses: { 201: ok("Created") } },
    },
    "/api/plans/{id}": {
      put: { tags: ["roast plans"], summary: "Update a roast plan", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" } }, ["plan"])), responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["roast plans"], summary: "Delete a roast plan", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },
    "/api/machine-profile": { get: { tags: ["roast plans"], summary: "Learned per-user machine profile", responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },

    // ── Actual roasts ──
    "/api/roasts": {
      get: { tags: ["actual roasts"], summary: "List uploaded finished roasts", parameters: [{ name: "plan", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Roasts", obj({ ok: bool, roasts: arr(roast) })) } },
      post: { tags: ["actual roasts"], summary: "Upload a finished Artisan .alog (starts async LLM review)", requestBody: jsonBody(obj({ roastPlanId: { ...str, nullable: true }, filename: str, content: { ...str, description: "raw .alog file text" } }, ["content"])), responses: { 201: ok("Stored", obj({ ok: bool, roast })), 422: err("Unparseable .alog") } },
    },
    "/api/roasts/{id}": {
      get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
      put: { tags: ["actual roasts"], summary: "Update the roast's machine and/or after-the-roast observations", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },
    "/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the .alog — original, or ?variant=updated for a supplemental copy with the app's data (weights, notes, cupping) written back as Artisan fields, importable into Artisan", parameters: [idParam, { name: "variant", in: "query", schema: { type: "string", enum: ["updated"] } }], responses: { 200: { description: "File as attachment" }, 404: err("Not found") } } },
    "/api/roasts/{id}/evaluate": { post: { tags: ["actual roasts"], summary: "Queue a re-review", parameters: [idParam], responses: { 200: ok("Queued"), 404: err("Not found") } } },

    // ── Green inventory ──
    "/api/inventory": {
      get: { tags: ["green inventory"], summary: "List green bean lots", responses: { 200: ok("Lots", obj({ ok: bool, lots: arr(lot) })) } },
      post: { tags: ["green inventory"], summary: "Add a lot", requestBody: jsonBody(obj({ origin: str, initialWeightG: { type: "number" } }, ["origin", "initialWeightG"])), responses: { 201: ok("Created") } },
    },
    "/api/inventory/{id}": {
      get: { tags: ["green inventory"], summary: "Lot detail + consumption log", parameters: [idParam], responses: { 200: ok("Lot"), 404: err("Not found") } },
      put: { tags: ["green inventory"], summary: "Update a lot", parameters: [idParam], responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["green inventory"], summary: "Delete a lot", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },
    "/api/inventory/{id}/consume": { post: { tags: ["green inventory"], summary: "Draw weight from a lot (idempotent per plan)", parameters: [idParam], requestBody: jsonBody(obj({ weightG: { type: "number" }, roastPlanId: { ...str, nullable: true } }, ["weightG"])), responses: { 201: ok("Drawn"), 409: err("Already consumed for that plan") } } },
    "/api/inventory/{id}/last-refine": { get: { tags: ["green inventory"], summary: "Most recent 'one change next batch' note for the lot", parameters: [idParam], responses: { 200: ok("Refine suggestion"), 404: err("Not found") } } },

    // ── Cupping ──
    "/api/cupping": {
      get: { tags: ["cupping"], summary: "List cupping sessions", parameters: [{ name: "plan", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Sessions") } },
      post: { tags: ["cupping"], summary: "Create a session", requestBody: jsonBody(obj({ roastPlanId: { ...str, nullable: true }, cupCount: { type: "integer" } }), false), responses: { 201: ok("Created") } },
    },
    "/api/cupping/{id}": {
      get: { tags: ["cupping"], summary: "Session detail", parameters: [idParam], responses: { 200: ok("Session"), 404: err("Not found") } },
      put: { tags: ["cupping"], summary: "Update a session", parameters: [idParam], requestBody: jsonBody(obj({ data: { type: "object" } }, ["data"])), responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["cupping"], summary: "Delete a session", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },

    // ── Roasted beans (brewing) ──
    "/api/beans": {
      get: { tags: ["beans"], summary: "List roasted/purchased beans", responses: { 200: ok("Beans", obj({ ok: bool, beans: arr(bean) })) } },
      post: { tags: ["beans"], summary: "Add a bean", requestBody: jsonBody(beanBody), responses: { 201: ok("Created", obj({ ok: bool, bean })), 400: err("Invalid") } },
    },
    "/api/beans/{id}": {
      put: { tags: ["beans"], summary: "Update a bean", parameters: [idParam], requestBody: jsonBody(beanBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["beans"], summary: "Delete a bean", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },
    "/api/gear": {
      get: { tags: ["brews"], summary: "Owned equipment (brewers + grinders)", responses: { 200: ok("Gear", obj({ ok: bool, gear: obj({ brewers: arr(str), grinders: arr(str) }) })) } },
      put: { tags: ["brews"], summary: "Set owned equipment", requestBody: jsonBody(obj({ brewers: arr({ ...str, description: "brew-method keys" }), grinders: arr(str) }, ["brewers", "grinders"])), responses: { 200: ok("Saved"), 400: err("Unknown brewer key") } },
    },
    "/api/brew-methods": { get: { tags: ["brews"], summary: "Brew-method taxonomy (categories + methods)", responses: { 200: ok("Methods", obj({ ok: bool, categories: arr(obj({ key: str, name: str })), methods: arr(obj({ key: str, name: str, category: str })) })) } } },

    // ── Brews ──
    "/api/brews": {
      get: { tags: ["brews"], summary: "List brews (newest first)", parameters: [{ name: "bean", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Brews", obj({ ok: bool, brews: arr(brew) })) } },
      post: { tags: ["brews"], summary: "Log a brew", requestBody: jsonBody(brewBody), responses: { 201: ok("Created", obj({ ok: bool, brew })), 400: err("Invalid") } },
    },
    "/api/brews/{id}": {
      put: { tags: ["brews"], summary: "Update a brew", parameters: [idParam], requestBody: jsonBody(brewBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
      delete: { tags: ["brews"], summary: "Delete a brew", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
    },

    // ── LLM helpers ──
    "/api/plan-chat": { post: { tags: ["llm"], summary: "Chat with the LLM about a roast plan (stateless; send the full visible conversation)", requestBody: jsonBody(obj({ plan: { type: "object" }, messages: arr(obj({ role: { ...str, enum: ["user", "assistant"] }, content: str }, ["role", "content"])) }, ["plan", "messages"])), responses: { 200: ok("Reply", obj({ ok: bool, reply: str })), 400: err("Bad plan/messages"), 503: err("No LLM model configured") } } },
    "/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned behavior for a machine (?roaster=id; default roaster otherwise), with user overrides applied", parameters: [{ name: "roaster", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
    "/api/roasters": {
      get: { tags: ["roasters"], summary: "List your roasting machines", responses: { 200: ok("Roasters") } },
      post: { tags: ["roasters"], summary: "Add a machine (first one becomes default)", requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool }, ["name"])), responses: { 201: ok("Created") } },
    },
    "/api/roasters/{id}": {
      put: { tags: ["roasters"], summary: "Update a machine (name/model/notes/default/override tweaks)", parameters: [idParam], requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool, overrides: { type: "object", description: "chargeTempC/turningPointS/turningPointTempC/yellowTempC/firstCrackTempC/dropTempC/paceFactor" } }), false), responses: { 200: ok("Updated"), 400: err("Bad override") } },
      delete: { tags: ["roasters"], summary: "Delete a machine (its roasts detach)", parameters: [idParam], responses: { 200: ok("Deleted") } },
    },
    "/api/prefill": { post: { tags: ["llm"], summary: "Extract coffee facts from a product URL (used by roast planner and bean form)", requestBody: jsonBody(obj({ url: str }, ["url"])), responses: { 200: ok("Extraction + derived worksheet fields"), 422: err("Extraction failed"), 503: err("No LLM model configured") } } },
    "/api/alog": { post: { tags: ["llm"], summary: "Parse an Artisan .alog for the reference-curve overlay (no storage)", requestBody: jsonBody(obj({ filename: str, content: str }, ["content"])), responses: { 200: ok("Parsed"), 422: err("Unparseable") } } },

    // ── Account ──
    "/api/account/email": { put: { tags: ["account"], summary: "Change email", requestBody: jsonBody(obj({ email: str, password: str }, ["email", "password"])), responses: { 200: ok("Changed") } } },
    "/api/account/password": { put: { tags: ["account"], summary: "Change password", requestBody: jsonBody(obj({ currentPassword: str, newPassword: str }, ["currentPassword", "newPassword"])), responses: { 200: ok("Changed") } } },
    "/api/account/sessions": { get: { tags: ["account"], summary: "List sessions", responses: { 200: ok("Sessions") } } },
    "/api/account/sessions/{id}": { delete: { tags: ["account"], summary: "Revoke a session", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked") } } },
    "/api/account/sessions/revoke-others": { post: { tags: ["account"], summary: "Revoke all other sessions", responses: { 200: ok("Revoked") } } },
    "/api/account/export": { get: { tags: ["account"], summary: "Download all of your own data as JSON", responses: { 200: ok("Personal data export") } } },
    "/api/account/avatar": {
      get: { tags: ["account"], summary: "Your profile picture (image response)", responses: { 200: { description: "Image" }, 404: err("No picture set") } },
      put: { tags: ["account"], summary: "Set your profile picture (small data URL)", requestBody: jsonBody(obj({ dataUrl: { ...str, description: "data:image/png|jpeg|webp;base64,… (≤300KB)" } }, ["dataUrl"])), responses: { 200: ok("Saved"), 400: err("Bad image") } },
      delete: { tags: ["account"], summary: "Remove your profile picture", responses: { 200: ok("Removed") } },
    },
    "/api/account": { delete: { tags: ["account"], summary: "Delete your account", requestBody: jsonBody(obj({ password: str }, ["password"])), responses: { 200: ok("Deleted") } } },

    // ── Admin ──
    "/api/admin/users": { get: { tags: ["admin"], summary: "List users", responses: { 200: ok("Users"), 403: err("Forbidden") } } },
    "/api/admin/metrics": { get: { tags: ["admin"], summary: "Instance metrics", responses: { 200: ok("Metrics") } } },
    "/api/admin/plans": { get: { tags: ["admin"], summary: "All plans (optional ?user=)", parameters: [{ name: "user", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Plans") } } },
    "/api/admin/audit": { get: { tags: ["admin"], summary: "Recent audit events", responses: { 200: ok("Events") } } },
    "/api/admin/password-resets": { get: { tags: ["admin"], summary: "Pending reset links (no-SMTP deployments)", responses: { 200: ok("Links") } } },
    "/api/admin/signup-enabled": { put: { tags: ["admin"], summary: "Toggle signups", requestBody: jsonBody(obj({ enabled: bool }, ["enabled"])), responses: { 200: ok("Toggled") } } },
    "/api/admin/llm": {
      get: { tags: ["admin"], summary: "Configured LLM models + current choice", responses: { 200: ok("Models", obj({ ok: bool, current: str, models: arr(obj({ key: str, name: str, provider: str })), modelsError: { ...str, nullable: true } })) } },
      put: { tags: ["admin"], summary: "Choose the LLM model ('' = auto)", requestBody: jsonBody(obj({ model: str }, ["model"])), responses: { 200: ok("Saved"), 400: err("Unknown model"), 503: err("Model listing unavailable") } },
    },
    "/api/admin/users/{id}/role": { put: { tags: ["admin"], summary: "Change a user's role", parameters: [idParam], requestBody: jsonBody(obj({ role: { ...str, enum: ["user", "admin"] } }, ["role"])), responses: { 200: ok("Changed") } } },
    "/api/admin/users/{id}/disabled": { put: { tags: ["admin"], summary: "Disable/enable a user", parameters: [idParam], requestBody: jsonBody(obj({ disabled: bool }, ["disabled"])), responses: { 200: ok("Changed") } } },
    "/api/admin/users/{id}": { delete: { tags: ["admin"], summary: "Delete a user and their data", parameters: [idParam], responses: { 200: ok("Deleted") } } },
    "/api/admin/backup": { get: { tags: ["admin"], summary: "Export the entire database as JSON", responses: { 200: ok("Backup file (attachment)") } } },
    "/api/admin/backup/import": { post: { tags: ["admin"], summary: "REPLACE the entire database from a backup export", requestBody: jsonBody(obj({ format: { ...str, example: "roast-planner-backup" }, version: { type: "integer", example: 1 }, tables: { type: "object" } }, ["format", "version", "tables"])), responses: { 200: ok("Imported", obj({ ok: bool, counts: { type: "object" }, sessionKept: bool })), 400: err("Bad backup / no admin in backup") } } },
  };

  return {
    openapi: "3.0.3",
    info: {
      title: "Roast Planner API",
      version: "1.0.0",
      description:
        "Every capability of the app — roast planning, finished-roast uploads with LLM review, green inventory, cupping, roasted-bean management, brew logging, account, and admin — over JSON. Authenticate with the browser session cookie or an API token (`Authorization: Bearer rpt_…`, generated on the Account page). Bearer requests skip CSRF; cookie-based write requests must send the `x-csrf-token` header.",
    },
    servers: [{ url: origin || "/" }],
    tags: [
      { name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" }, { name: "roasters" },
      { name: "green inventory" }, { name: "cupping" }, { name: "beans" }, { name: "brews" },
      { name: "llm" }, { name: "account" }, { name: "admin" },
    ],
    components: {
      securitySchemes: {
        cookieAuth: { type: "apiKey", in: "cookie", name: "rp_session" },
        bearerAuth: { type: "http", scheme: "bearer", description: "API token from the Account page (rpt_…)" },
      },
    },
    security,
    paths,
  };
}
