import { z } from "zod";

// See schemas/auth.ts for the shared client/server validation rationale.

/**
 * One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
 *
 * A line carries **exactly one** of:
 * - `ingredientId` — references an existing reference `Ingredient` (see
 *   `GET /reference/ingredients`). An unknown id is rejected service-side
 *   with `INGREDIENT_NOT_FOUND`, not here — this schema only checks shape.
 *   (A placeholder that already exists, e.g. on a recipe being edited,
 *   round-trips through this branch by its real id.)
 * - `placeholderName` — free text the user typed because nothing in the
 *   catalog matched. The API creates a dedicated placeholder `Ingredient`
 *   row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
 *   `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
 *
 * Requiring exactly one keeps `RecipeIngredient` unchanged (still a real
 * `ingredientId` after the service resolves the line).
 */
const recipeIngredientInputSchema = z
  .object({
    ingredientId: z.number().int().positive().optional(),
    /** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
    placeholderName: z.string().trim().min(1).max(120).optional(),
    quantity: z.number().positive("La quantité doit être positive"),
    /** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
    unitId: z.number().int().positive(),
  })
  .refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
    message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
    path: ["ingredientId"],
  });

/**
 * One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
 * `order` is deliberately not part of this shape — it's derived server-side
 * from the step's position in the `steps` array, so the client (the
 * step reorder UI) never has to keep an explicit order field in sync.
 */
const recipeStepInputSchema = z.object({
  description: z.string().trim().min(1, "La description de l'étape est requise").max(2000),
  picture: z.string().trim().url("URL invalide").nullable().optional(),
});

/**
 * Who can read the recipe being created/edited — mirrors `RecipeVisibility`
 * in schema.prisma. Defaults to `PERSONAL` (visible to its author only) —
 * the author explicitly opens it up to `HOUSE`/`PUBLIC` if they want to
 * share it, rather than the other way around.
 */
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);

/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
export const createRecipeSchema = z
  .object({
    name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
    description: z.string().trim().max(2000).nullable().optional(),
    picture: z.string().trim().url("URL invalide").nullable().optional(),
    /** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
    portions: z.number().int().positive("Le nombre de portions doit être positif"),
    visibility: recipeVisibilitySchema.default("PERSONAL"),
    /** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
    dietIds: z.array(z.number().int().positive()),
    ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
    steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
  })
  // `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — one row
  // per ingredient per recipe (schema.prisma) — so two lines with the same
  // `ingredientId` would otherwise reach `prisma.recipe.create()` and crash
  // with an unhandled `P2002` unique-constraint 500, instead of the clean
  // 400 every other invalid shape gets here. The manual recipe form can't
  // produce this (`IngredientPicker`'s `excludeIds` hides an
  // already-selected ingredient), but a source import can: two raw lines
  // ("Egg Yolks", "Eggs") can independently resolve to the same catalog
  // ingredient (see `RecipeImportForm.tsx`, issue #53's `matchUnit`
  // fallback made this reachable in practice) — rejected here rather than
  // silently summed, since two lines resolving to the same ingredient
  // aren't necessarily interchangeable quantities (different units,
  // different confidence in the match).
  //
  // Placeholder lines (`placeholderName`, no `ingredientId` yet) are
  // skipped here: each one becomes its own fresh `Ingredient` row
  // service-side, so two placeholder lines with the same text never
  // collide on the `(recipeId, ingredientId)` key.
  .refine(
    (input) => {
      const ingredientIds = input.ingredients
        .map((ingredient) => ingredient.ingredientId)
        .filter((id): id is number => id !== undefined);
      return new Set(ingredientIds).size === ingredientIds.length;
    },
    {
      message: "Un même ingrédient ne peut pas apparaître plusieurs fois dans une recette",
      path: ["ingredients"],
    },
  );
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;

/** `PATCH /recipes/:id` shares the exact same shape as creation — see {@link createRecipeSchema}. */
export const updateRecipeSchema = createRecipeSchema;
/** Inferred TS type for {@link updateRecipeSchema}'s validated output. */
export type UpdateRecipeInput = z.infer<typeof updateRecipeSchema>;

/**
 * Which catalog tab `GET /recipes` should filter for — see
 * `recipe.service.ts`'s `listRecipes` for what each value actually
 * queries. No "toutes" value on purpose: every recipe visible to a viewer
 * falls under exactly one of `perso`/`foyer`/`publique` (its own
 * visibility), `favoris` is an orthogonal, cross-cutting filter on top.
 */
export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]);
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
export type RecipeTab = z.infer<typeof recipeTabSchema>;

/**
 * A repeated query param (`?ingredientIds=1&ingredientIds=2`) arrives via
 * Express/`qs` as a plain string when there's exactly one, or a string
 * array when there's more than one — never a bare array for a single
 * value. Normalizes both (plus the "absent" case) into `number[] |
 * undefined` before the real `z.array` check runs.
 */
function queryIdArray() {
  return z.preprocess(
    (value) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]),
    z.array(z.coerce.number().int().positive()).optional(),
  );
}

/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring, `suitableForHousehold` (the planning recipe picker's "convient à tout le foyer" toggle) further restricts to recipes that avoid every household member's declared allergens and match every member's declared regime (see `recipe.service.ts`'s `listRecipes`) — a no-op if the caller has no household. `ingredientIds`/`dietIds` (the same picker's ingredient/regime filters) further restrict to recipes carrying *every* id listed (AND, not "any of") — see {@link queryIdArray}. `z.coerce.boolean()` since query params always arrive as strings. */
export const listRecipesSchema = z.object({
  tab: recipeTabSchema,
  search: z.string().trim().min(1).optional(),
  suitableForHousehold: z.coerce.boolean().optional(),
  ingredientIds: queryIdArray(),
  dietIds: queryIdArray(),
});
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;

/**
 * One ingredient mention the user themselves points at while correcting a
 * technique — `start`/`end` is *their own* selection of the exact passage
 * of `description` that names it (a separate selection from the
 * correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
 * not derived from anything the classifier found. `quantity`/`unitId`
 * are optional — a mention with no quantity attached ("ajouter le sel")
 * is still worth recording. See `submitTechStepCorrectionSchema`'s own
 * doc comment for how `ingredients` as a whole behaves.
 */
const manualStepTechStepIngredientInputSchema = z
  .object({
    ingredientId: z.number().int().positive(),
    quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
    unitId: z.number().int().positive().nullable().optional(),
    start: z.number().int().nonnegative(),
    end: z.number().int().nonnegative(),
  })
  .refine((ingredient) => ingredient.end > ingredient.start, {
    message: "end must be greater than start",
    path: ["end"],
  });

/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
const manualStepTechStepUtensilInputSchema = z
  .object({
    utensilId: z.number().int().positive(),
    start: z.number().int().nonnegative(),
    end: z.number().int().nonnegative(),
  })
  .refine((utensil) => utensil.end > utensil.start, {
    message: "end must be greater than start",
    path: ["end"],
  });

/**
 * Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
 * user asserting what technique a `[start, end)` span of a step's
 * `description` should (or shouldn't) be tagged with. `previousTechStepId`
 * is the existing match being corrected (omit/`null` when the user is
 * flagging a technique the classifier missed entirely — nothing to
 * correct, just to add); `correctedTechStepId` is what they assert instead
 * (omit/`null` means "no technique belongs here", i.e. removing a wrong
 * match). Rejecting both being absent at once happens service-side
 * (`recipe-tech-step-correction.service.ts`) — needs the target step's
 * `description` length to validate `start`/`end` against, which this shape
 * alone can't see.
 *
 * `ingredients`/`utensils` let the user attach metadata to the technique
 * they're asserting (`correctedTechStepId`), same `source: "manual"`
 * distinction the technique itself gets. **Omitted (`undefined`) means
 * "leave whatever metadata already exists on this occurrence alone" —
 * an explicit array, even `[]`, means "this is now the complete set,
 * replace everything that was there" (auto-detected included; see
 * `applyManualCorrection`'s own doc comment). This is why neither field
 * has a `.default([])`: that would silently turn every plain relabel into
 * a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
 * — enforced by this schema's own refine below, since there's no live
 * `StepTechStep` row to attach to otherwise (removing a match, or a
 * request with neither id set).
 */
export const submitTechStepCorrectionSchema = z
  .object({
    start: z.number().int().nonnegative(),
    end: z.number().int().nonnegative(),
    previousTechStepId: z.number().int().positive().nullable().optional(),
    correctedTechStepId: z.number().int().positive().nullable().optional(),
    ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
    utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
  })
  .refine((input) => input.end > input.start, {
    message: "end must be greater than start",
    path: ["end"],
  })
  .refine(
    (input) =>
      (input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
    {
      message: "at least one of previousTechStepId/correctedTechStepId is required",
      path: ["correctedTechStepId"],
    },
  )
  .refine(
    (input) =>
      (input.ingredients === undefined && input.utensils === undefined) ||
      (input.correctedTechStepId ?? null) !== null,
    {
      message: "ingredients/utensils require a correctedTechStepId to attach to",
      path: ["correctedTechStepId"],
    },
  );
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
