import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
  type AllergyView,
  type CreateRecipeInput,
  type DietView,
  ErrorCode,
  type IngredientView,
  type RecipeSummaryView,
  type RecipeTab,
  type RecipeView,
  type StepTechStepView,
  type UnitView,
  type UpdateRecipeInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import {
  type TechStepMatch,
  techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js";

// No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are
// matched against this hardcoded locale for now. See
// `tech-step-matcher.ts`'s `TechStepClassifierService` for why the locale
// is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr";

/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
function recipeInclude(viewerId: number) {
  return {
    ingredients: {
      include: {
        ingredient: {
          include: {
            allergies: {
              include: { allergy: { include: { category: true } } },
            },
            diets: { include: { diet: true } },
          },
        },
        unit: true,
      },
    },
    steps: {
      orderBy: { order: "asc" },
      include: {
        techSteps: {
          orderBy: { order: "asc" },
          include: {
            techStep: true,
            // Same `allergies`/`diets` nesting as this function's own
            // top-level `ingredients` include above — reused by
            // `toIngredientView` so a mentioned ingredient resolves to the
            // exact same `IngredientView` shape as the recipe's main
            // ingredient list, not a second, thinner shape.
            ingredients: {
              include: {
                ingredient: {
                  include: {
                    allergies: { include: { allergy: { include: { category: true } } } },
                    diets: { include: { diet: true } },
                  },
                },
                unit: true,
              },
            },
            utensils: { include: { utensil: true } },
          },
        },
      },
    },
    diets: { include: { diet: true } },
    favoritedBy: { where: { userProfileId: viewerId } },
  } satisfies Prisma.RecipeInclude;
}

type RecipeWithDetails = Prisma.RecipeGetPayload<{
  include: ReturnType<typeof recipeInclude>;
}>;
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];

/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
export function toUnitView(unit: UnitWithDetails): UnitView {
  return {
    id: unit.id,
    key: unit.key,
    type: unit.type,
    toBaseFactor: Number(unit.toBaseFactor),
  };
}

/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
  return {
    id: ingredient.id,
    key: ingredient.key,
    icon: ingredient.icon,
    category: ingredient.category,
    subcategory: ingredient.subcategory,
    reproducible: ingredient.reproducible,
    allergens: ingredient.allergies.map(({ allergy }) => ({
      id: allergy.id,
      key: allergy.category.key,
      kind: allergy.category.kind,
    })),
    diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
    // A placeholder line round-trips as a normal `Ingredient` with a real
    // id — the frontend tells it apart by this flag (badge, no
    // allergen/diet info) and shows `displayName` verbatim instead of
    // looking up an i18n label that doesn't exist for a `placeholder:` key.
    isPlaceholder: ingredient.isPlaceholder,
    displayName: ingredient.displayName,
  };
}

function toDietView(diet: { id: number; key: string }): DietView {
  return { id: diet.id, key: diet.key };
}

/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
  const byId = new Map<number, AllergyView>();
  for (const ingredient of ingredients) {
    for (const allergen of ingredient.allergens) {
      byId.set(allergen.id, allergen);
    }
  }
  return [...byId.values()];
}

/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
  const allergens = aggregateAllergens(
    recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
  );
  return {
    id: recipe.id,
    name: recipe.name,
    description: recipe.description,
    picture: recipe.picture,
    portions: recipe.portions,
    authorId: recipe.authorId,
    visibility: recipe.visibility,
    allergens,
    diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
    isFavorite: recipe.favoritedBy.length > 0,
  };
}

/**
 * Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row
 * whose `start`/`end` is still `null` (a pre-existing row saved before that
 * column pair existed, not yet recomputed by a resave — see the schema doc
 * comment on `StepTechStep`) is dropped rather than surfaced with a null
 * span, so the frontend only ever deals with real, highlightable matches.
 * `contextStart`/`contextEnd` are treated more leniently — a row with a
 * real keyword span but no context (saved before *that* column pair
 * existed) still has a perfectly good match to show, just without the
 * wider highlight, so those two are included only when both are present
 * rather than dropping the whole entry over a still-missing "nice to have".
 *
 * Exported — also called by `recipe-tech-step-correction.service.ts` to
 * shape the fresh `StepTechStep` sequence it returns right after applying
 * a manual correction, so both places convert the exact same way rather
 * than risking two slightly different views of the same rows.
 */
export function toStepTechStepViews(
  techSteps: RecipeWithDetails["steps"][number]["techSteps"],
): StepTechStepView[] {
  const views: StepTechStepView[] = [];
  for (const stepTechStep of techSteps) {
    const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
      stepTechStep;
    if (start === null || end === null) continue;
    views.push({
      techStep: { id: techStep.id, key: techStep.key },
      start,
      end,
      // `source` is a plain DB `String`, not a Prisma enum (see
      // `StepTechStep`'s schema doc comment) — narrowed here rather than
      // trusting the column's own type, so a value this app never wrote
      // (a manual DB edit, a future migration gone wrong) degrades to the
      // safer "auto" reading instead of surfacing an invalid
      // `StepTechStepView.source` to the frontend.
      source: source === "manual" ? "manual" : "auto",
      ...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
      ingredients: ingredients.map((stepTechStepIngredient) => ({
        ingredient: toIngredientView(stepTechStepIngredient.ingredient),
        quantity:
          stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity),
        unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
        start: stepTechStepIngredient.start,
        end: stepTechStepIngredient.end,
        // Same narrowing posture as the technique's own `source` above.
        source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
      })),
      utensils: utensils.map((stepTechStepUtensil) => ({
        utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
        start: stepTechStepUtensil.start,
        end: stepTechStepUtensil.end,
        source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
      })),
    });
  }
  return views;
}

/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
  const ingredients = recipe.ingredients.map((recipeIngredient) => ({
    ingredient: toIngredientView(recipeIngredient.ingredient),
    quantity: Number(recipeIngredient.quantity),
    unit: toUnitView(recipeIngredient.unit),
  }));
  return {
    ...toRecipeSummaryView(recipe),
    ingredients,
    steps: recipe.steps.map((step) => ({
      id: step.id,
      description: step.description,
      picture: step.picture,
      order: step.order,
      techSteps: toStepTechStepViews(step.techSteps),
    })),
  };
}

/**
 * True if `viewerId`/`viewerHouseId` may *read* this recipe — the author
 * always can, whatever the current visibility (even a `HOUSE` recipe if
 * they've since left that household — access to your own creations never
 * regresses). Otherwise follows `visibility` as documented on
 * `RecipeVisibility` in schema.prisma.
 */
function canView(
  recipe: {
    authorId: number;
    authorHouseId: number | null;
    visibility: string;
  },
  viewerId: number,
  viewerHouseId: number | null,
): boolean {
  if (recipe.authorId === viewerId) return true;
  if (recipe.visibility === "PUBLIC") return true;
  if (recipe.visibility === "HOUSE") {
    return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId;
  }
  return false;
}

/** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */
function visibleToViewerWhere(
  viewerId: number,
  viewerHouseId: number | null,
): Prisma.RecipeWhereInput {
  return {
    OR: [
      { authorId: viewerId },
      { visibility: "PUBLIC" },
      ...(viewerHouseId !== null
        ? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }]
        : []),
    ],
  };
}

/**
 * `Recipe` rows that avoid every member of `houseId`'s declared allergens
 * and, for every member with a declared regime, are tagged with that
 * regime — the planning recipe picker's "convient à tout le foyer" toggle
 * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
 * extra query to gather the household's members' allergy/regime ids)
 * rather than exposed to the client as raw per-member data: a member's
 * allergies/regime are private the same way visibility already keeps a
 * recipe's existence private (404, never 403) — nothing here should let
 * one member infer another's medical/dietary info from the shape of a
 * filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
 * the schema already treats disliked ingredients as a taste preference,
 * not a safety constraint (see that model's doc comment), so it doesn't
 * belong in a filter framed around what's safe/appropriate to serve.
 */
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
  try {
    const members = await prisma.userProfile.findMany({
      where: { houseId },
      select: { dietId: true, allergies: { select: { allergyId: true } } },
    });
    const requiredDietIds = [
      ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
    ];
    const excludedAllergyIds = [
      ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
    ];

    const conditions: Prisma.RecipeWhereInput[] = [];
    if (requiredDietIds.length > 0) {
      // Every diet declared by a member must be among this recipe's tags —
      // not "at least one", since a recipe suiting a vegetarian member
      // doesn't automatically suit a gluten-free one too.
      conditions.push({
        AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
      });
    }
    if (excludedAllergyIds.length > 0) {
      conditions.push({
        ingredients: {
          none: {
            ingredient: {
              allergies: { some: { allergyId: { in: excludedAllergyIds } } },
            },
          },
        },
      });
    }
    return { AND: conditions };
  } catch (err) {
    // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
    // already logs it, see `error-logger.ts`) is what actually handles it,
    // this service layer just isn't allowed a bare `await`/`async` body
    // without a try/catch per the repo's convention.
    throw err;
  }
}

/**
 * `Recipe` rows a household is allowed to see given which sources it has
 * enabled (`HouseSource`, schema.prisma — opt-in, no row means hidden).
 * Applied unconditionally in {@link listRecipes}, across every tab: a
 * manually-authored recipe (`sourceId` `null`) is always visible, this
 * only ever hides a recipe that came from an external source the viewer's
 * household hasn't turned on. A viewer with no household yet
 * (`houseId === null`) has nothing enabled by construction (there's no
 * household row for `HouseSource` to reference), so every sourced recipe
 * is hidden for them until they join or create one and configure it.
 */
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
  try {
    const enabledSourceIds =
      houseId === null
        ? []
        : (
            await prisma.houseSource.findMany({
              where: { houseId },
              select: { sourceId: true },
            })
          ).map((row) => row.sourceId);
    return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Optional narrowing filters for {@link listRecipes}, on top of the
 * mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object
 * rather than a growing list of positional optional params now that the
 * planning recipe picker adds two more on top of `search`/
 * `suitableForHousehold`.
 */
export interface ListRecipesFilters {
  /** Case-insensitive name substring. */
  search?: string;
  /** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
  suitableForHousehold?: boolean;
  /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
  ingredientIds?: number[];
  /** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
  dietIds?: number[];
}

/**
 * The recipes visible to `viewerId` under one catalog tab, alphabetically,
 * optionally filtered further (see {@link ListRecipesFilters}). No
 * "toutes" tab — every recipe a viewer can see falls under exactly one of
 * `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
 * orthogonal, cross-cutting filter on top (and re-applies
 * {@link visibleToViewerWhere} in case access to a previously-favorited
 * recipe has since changed, e.g. leaving the house that granted it).
 */
export async function listRecipes(
  viewerId: number,
  viewerHouseId: number | null,
  tab: RecipeTab,
  filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
  try {
    const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
    const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
    if (search) {
      conditions.push({ name: { contains: search, mode: "insensitive" } });
    }
    // No-op without a household — nothing to filter against, same posture as
    // the `foyer` tab returning everything it can rather than throwing.
    if (suitableForHousehold && viewerHouseId !== null) {
      conditions.push(await suitableForHouseholdWhere(viewerHouseId));
    }
    if (ingredientIds && ingredientIds.length > 0) {
      // One condition per required id (AND) — a recipe must carry all of
      // them, not just one, same "every one, not any one" posture as
      // suitableForHouseholdWhere's requiredDietIds.
      conditions.push({
        AND: ingredientIds.map((ingredientId) => ({
          ingredients: { some: { ingredientId } },
        })),
      });
    }
    if (dietIds && dietIds.length > 0) {
      conditions.push({
        AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
      });
    }

    switch (tab) {
      case "favoris":
        conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
        conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
        break;
      case "perso":
        conditions.push({ visibility: "PERSONAL", authorId: viewerId });
        break;
      case "foyer":
        // No household — nothing can carry this viewer's authorHouseId.
        if (viewerHouseId === null) return [];
        conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
        break;
      case "publique":
        conditions.push({ visibility: "PUBLIC" });
        break;
    }

    const recipes = await prisma.recipe.findMany({
      where: { AND: conditions },
      include: recipeInclude(viewerId),
      orderBy: { name: "asc" },
    });
    return recipes.map(toRecipeSummaryView);
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * A single recipe's full detail.
 *
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one).
 */
export async function getRecipe(
  id: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<RecipeView> {
  try {
    const recipe = await findRecipeOrThrow(id, viewerId);
    if (!canView(recipe, viewerId, viewerHouseId)) {
      throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
    }
    return toRecipeView(recipe);
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Creates a recipe with its ingredients, ordered steps and diet tags in one
 * go — steps' `order` is derived from their position in `input.steps`,
 * ingredients reference existing reference `Ingredient` rows by id (see
 * `GET /reference/ingredients`; there's no way to create one here).
 * `authorId`/`authorHouseId` are fixed at creation and never change on
 * later edits (see {@link updateRecipe}).
 *
 * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
 * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
 * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
 */
export async function createRecipe(
  input: CreateRecipeInput,
  authorId: number,
  authorHouseId: number | null,
): Promise<RecipeView> {
  try {
    return await createRecipeInternal(input, authorId, authorHouseId, null);
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Finalizes an import from an external source — same validation/creation
 * path as {@link createRecipe} (by the time this is called, `input` has
 * already been reviewed and every ingredient resolved to a real catalog
 * id, same as a manual creation — see `sources.service.ts`'s
 * `importSourceItem`, the only caller), plus stamping `sourceId`/
 * `externalId` and matching techniques against `locale` (the source's own
 * — e.g. `"en"` for TheMealDB) instead of the hardcoded French default,
 * since the step text is still in whatever language the source wrote it
 * in.
 *
 * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
 * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
 * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
 */
export async function createImportedRecipe(
  input: CreateRecipeInput,
  authorId: number,
  authorHouseId: number | null,
  source: { sourceId: number; externalId: string; locale: string },
): Promise<RecipeView> {
  try {
    return await createRecipeInternal(input, authorId, authorHouseId, source);
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** One input step, bundled with its own technique matches — see {@link matchStepsTechSteps}. */
interface StepWithTechSteps<T> {
  step: T;
  matches: TechStepMatch[];
}

/**
 * Matches every one of `steps`' technique sequence against `locale`, in
 * parallel, each bundled back with its own originating step (rather than
 * returned as a same-length array callers would have to re-zip with
 * `steps` by index — `noUncheckedIndexedAccess` makes that genuinely
 * awkward for no benefit, since every match list is only ever read back
 * once) — the shared prep step {@link createRecipeInternal}/
 * {@link updateRecipe} both need before building their (synchronous)
 * Prisma `create` payload, now that matching itself is async
 * (`techStepClassifier`, a trained model rather than a pure regex test —
 * see `tech-step-matcher.ts`).
 */
async function matchStepsTechSteps<T extends { description: string }>(
  steps: T[],
  locale: string,
): Promise<StepWithTechSteps<T>[]> {
  return Promise.all(
    steps.map(async (step) => ({
      step,
      matches: await techStepClassifier.matchTechStepSpans(step.description, locale),
    })),
  );
}

/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
interface ResolvedIngredientLine {
  ingredientId: number;
  quantity: number;
  unitId: number;
}

/**
 * Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
 * a line that already carries an `ingredientId` (a catalog pick, or an
 * existing placeholder round-tripping through an edit) passes through
 * unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
 * row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
 * typed text as `displayName`, stamped with `authorId`/now) created via
 * `tx` — so it rolls back together with the recipe if anything later in the
 * same transaction fails, never leaving an orphan behind.
 *
 * Returns the created placeholders separately so the caller can emit one
 * `ingredient.placeholder_created` analytics event per row *after* the
 * transaction commits (the recipe id it wants in the event context doesn't
 * exist yet in here).
 */
async function resolveIngredientLines(
  lines: CreateRecipeInput["ingredients"],
  authorId: number,
  tx: Prisma.TransactionClient,
): Promise<{
  resolved: ResolvedIngredientLine[];
  createdPlaceholders: { id: number; name: string }[];
}> {
  try {
    const resolved: ResolvedIngredientLine[] = [];
    const createdPlaceholders: { id: number; name: string }[] = [];
    for (const line of lines) {
      if (line.ingredientId !== undefined) {
        resolved.push({
          ingredientId: line.ingredientId,
          quantity: line.quantity,
          unitId: line.unitId,
        });
        continue;
      }
      // `recipeIngredientInputSchema`'s refine guarantees the other branch.
      const name = (line.placeholderName ?? "").trim();
      const placeholder = await tx.ingredient.create({
        data: {
          key: `placeholder:${randomUUID()}`,
          isPlaceholder: true,
          displayName: name,
          createdById: authorId,
          createdAt: new Date(),
        },
        select: { id: true },
      });
      resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
      createdPlaceholders.push({ id: placeholder.id, name });
    }
    return { resolved, createdPlaceholders };
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

async function createRecipeInternal(
  input: CreateRecipeInput,
  authorId: number,
  authorHouseId: number | null,
  source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
  try {
    await assertIngredientsExist(
      input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
    );
    await assertUnitsExist(input.ingredients.map((i) => i.unitId));
    await assertDietsExist(input.dietIds);
    // Matched up front (one call per step, in parallel) rather than inline
    // inside the `steps.create` map below — `techStepClassifier` is async
    // (a trained model, not a pure regex test), so its result has to
    // already be in hand by the time this synchronous Prisma payload is
    // built.
    const stepsWithTechSteps = await matchStepsTechSteps(
      input.steps,
      source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
    );

    // One transaction so the free-text placeholder `Ingredient` rows and the
    // recipe that references them commit together — a failed recipe create
    // must never leave orphan placeholders behind.
    const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => {
      const { resolved, createdPlaceholders } = await resolveIngredientLines(
        input.ingredients,
        authorId,
        tx,
      );
      const created = await tx.recipe.create({
        data: {
          name: input.name,
          description: input.description ?? null,
          picture: input.picture ?? null,
          portions: input.portions,
          authorId,
          authorHouseId,
          visibility: input.visibility,
          sourceId: source?.sourceId ?? null,
          externalId: source?.externalId ?? null,
          ingredients: {
            create: resolved.map((line) => ({
              ingredientId: line.ingredientId,
              quantity: line.quantity,
              unitId: line.unitId,
            })),
          },
          steps: {
            create: stepsWithTechSteps.map(({ step, matches }, index) => ({
              description: step.description,
              picture: step.picture ?? null,
              order: index,
              techSteps: {
                create: matches.map((match, order) => ({
                  techStepId: match.techStepId,
                  start: match.start,
                  end: match.end,
                  contextStart: match.contextStart,
                  contextEnd: match.contextEnd,
                  order,
                  ingredients: {
                    create: match.ingredients.map((ingredient) => ({
                      ingredientId: ingredient.ingredientId,
                      quantity: ingredient.quantity,
                      unitId: ingredient.unitId,
                      start: ingredient.start,
                      end: ingredient.end,
                    })),
                  },
                  utensils: {
                    create: match.utensils.map((utensil) => ({
                      utensilId: utensil.utensilId,
                      start: utensil.start,
                      end: utensil.end,
                    })),
                  },
                })),
              },
            })),
          },
          diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
        },
        include: recipeInclude(authorId),
      });
      return { created, createdPlaceholders };
    });

    analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
      actorId: authorId,
      context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
    });
    for (const placeholder of createdPlaceholders) {
      analytics.recordEvent("ingredient.placeholder_created", {
        actorId: authorId,
        context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id },
      });
    }

    return toRecipeView(created);
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Replaces a recipe's whole content — name/description/picture/visibility
 * and the complete ingredient/step/diet lists (not a partial merge: a line
 * missing from `input` is removed, same contract as `PATCH
 * /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
 * never transfers ownership.
 *
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
 * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
 * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
 * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
 * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
 */
export async function updateRecipe(
  id: number,
  input: UpdateRecipeInput,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<RecipeView> {
  try {
    await assertIsAuthor(id, viewerId, viewerHouseId);
    await assertIngredientsExist(
      input.ingredients
        .map((i) => i.ingredientId)
        .filter((ingredientId): ingredientId is number => ingredientId !== undefined),
    );
    await assertUnitsExist(input.ingredients.map((i) => i.unitId));
    await assertDietsExist(input.dietIds);
    const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);

    // Interactive transaction (not the array form) so any new free-text
    // placeholder rows are created in the same atomic unit as the
    // delete+recreate of the recipe's content. An *existing* placeholder
    // line round-trips by its real `ingredientId` and is left untouched;
    // only a brand-new `placeholderName` line creates a row here.
    const createdPlaceholders = await prisma.$transaction(async (tx) => {
      await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
      await tx.step.deleteMany({ where: { recipeId: id } });
      await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
      const { resolved, createdPlaceholders } = await resolveIngredientLines(
        input.ingredients,
        viewerId,
        tx,
      );
      await tx.recipe.update({
        where: { id },
        data: {
          name: input.name,
          description: input.description ?? null,
          picture: input.picture ?? null,
          portions: input.portions,
          visibility: input.visibility,
          ingredients: {
            create: resolved.map((line) => ({
              ingredientId: line.ingredientId,
              quantity: line.quantity,
              unitId: line.unitId,
            })),
          },
          steps: {
            create: stepsWithTechSteps.map(({ step, matches }, index) => ({
              description: step.description,
              picture: step.picture ?? null,
              order: index,
              techSteps: {
                create: matches.map((match, order) => ({
                  techStepId: match.techStepId,
                  start: match.start,
                  end: match.end,
                  contextStart: match.contextStart,
                  contextEnd: match.contextEnd,
                  order,
                })),
              },
            })),
          },
          diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
        },
      });
      return createdPlaceholders;
    });

    for (const placeholder of createdPlaceholders) {
      analytics.recordEvent("ingredient.placeholder_created", {
        actorId: viewerId,
        context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id },
      });
    }

    return toRecipeView(await findRecipeOrThrow(id, viewerId));
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Deletes a recipe outright — its ingredients/steps/diet tags/favorites
 * cascade away (see `onDelete: Cascade` in schema.prisma).
 *
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
 * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
 * @throws {HttpError} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation.
 */
export async function deleteRecipe(
  id: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<void> {
  try {
    await assertIsAuthor(id, viewerId, viewerHouseId);

    const usedInPlanning = await prisma.planningItem.findFirst({
      where: { recipeId: id },
    });
    if (usedInPlanning) {
      throw new HttpError(
        409,
        ErrorCode.RECIPE_IN_USE,
        "Recipe is still used by at least one planning item",
      );
    }

    await prisma.recipe.delete({ where: { id } });
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Favorites a recipe for `viewerId` — idempotent (favoriting an
 * already-favorited recipe is a no-op, not an error).
 *
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action.
 */
export async function addFavorite(
  id: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<void> {
  try {
    const recipe = await findRecipeOrThrow(id, viewerId);
    if (!canView(recipe, viewerId, viewerHouseId)) {
      throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
    }
    await prisma.recipeFavorite.upsert({
      where: {
        userProfileId_recipeId: { userProfileId: viewerId, recipeId: id },
      },
      update: {},
      create: { userProfileId: viewerId, recipeId: id },
    });
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
  try {
    await prisma.recipeFavorite.deleteMany({
      where: { userProfileId: viewerId, recipeId: id },
    });
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/**
 * Guard for other modules that need to confirm a recipe is visible to a
 * viewer before referencing it (e.g. `planning.service.ts`'s
 * `addPlanningItem`, before creating a `PlanningItem` pointing at it) —
 * exported rather than duplicating {@link canView} at the call site.
 *
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` — never `403`, same reasoning as `getRecipe`.
 */
export async function assertRecipeVisible(
  id: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<void> {
  try {
    const recipe = await findRecipeOrThrow(id, viewerId);
    if (!canView(recipe, viewerId, viewerHouseId)) {
      throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
    }
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
  try {
    const recipe = await prisma.recipe.findUnique({
      where: { id },
      include: recipeInclude(viewerId),
    });
    if (!recipe) {
      throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
    }
    return recipe;
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
async function assertIsAuthor(
  id: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<void> {
  try {
    const recipe = await findRecipeOrThrow(id, viewerId);
    if (!canView(recipe, viewerId, viewerHouseId)) {
      throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
    }
    if (recipe.authorId !== viewerId) {
      throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
    }
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(ingredientIds)];
    const found = await prisma.ingredient.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    if (found.length !== uniqueIds.length) {
      const foundIds = new Set(found.map((ingredient) => ingredient.id));
      const missing = uniqueIds.filter((id) => !foundIds.has(id));
      throw new HttpError(
        404,
        ErrorCode.INGREDIENT_NOT_FOUND,
        `Ingredient(s) not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
async function assertUnitsExist(unitIds: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(unitIds)];
    const found = await prisma.unit.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    if (found.length !== uniqueIds.length) {
      const foundIds = new Set(found.map((unit) => unit.id));
      const missing = uniqueIds.filter((id) => !foundIds.has(id));
      throw new HttpError(
        404,
        ErrorCode.UNIT_NOT_FOUND,
        `Unit(s) not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}

/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(dietIds)];
    if (uniqueIds.length === 0) return;
    const found = await prisma.diet.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    if (found.length !== uniqueIds.length) {
      const foundIds = new Set(found.map((diet) => diet.id));
      const missing = uniqueIds.filter((id) => !foundIds.has(id));
      throw new HttpError(
        404,
        ErrorCode.DIET_NOT_FOUND,
        `Diet(s) not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see suitableForHouseholdWhere()'s catch comment above
  }
}
