import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
  clearRecipeSources,
  registerRecipeSource,
} from "../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";

/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
  return {
    key,
    name,
    official: false,
    iconUrl: null,
    locale: "fr",
    async list() {
      return { items: [], nextCursor: null };
    },
    async fetchDetail() {
      throw new Error("not implemented");
    },
    parse() {
      throw new Error("not implemented");
    },
  };
}

/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
  const firstName = faker.person.firstName();
  const lastName = faker.person.lastName();
  return {
    firstName,
    lastName,
    email: faker.internet.email({ firstName, lastName }).toLowerCase(),
    password: faker.internet.password({ length: 16 }),
  };
}

/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`). */
async function ingredientId(key: string): Promise<number> {
  const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
  return ingredient.id;
}

/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
  const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
  return unit.id;
}

/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function techStepId(key: string): Promise<number> {
  const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
  return techStep.id;
}

/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/recipe-matching/tech-step-matcher.js`) so tests can assert on it directly. */
async function stepTechStepIds(stepId: number): Promise<number[]> {
  const links = await prisma.stepTechStep.findMany({
    where: { stepId },
    orderBy: { order: "asc" },
    select: { techStepId: true },
  });
  return links.map((link) => link.techStepId);
}

describe("Recipes", () => {
  const app = createApp();

  /** Signs up a fresh profile and returns both its session `agent` and profile id — most tests below need the id for `authorId` on directly-created fixture rows. */
  async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
    const agent = request.agent(app);
    const res = await agent.post("/auth/signup").send(buildSignupPayload());
    return { agent, profileId: res.body.id };
  }

  beforeEach(async () => {
    await resetDatabase();
  });

  after(async () => {
    await prisma.$disconnect();
  });

  describe("GET /recipes", () => {
    it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
      const res = await request(app).get("/recipes").query({ tab: "publique" });

      expect(res.status).to.equal(401);
      expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
    });

    it("rejects a missing ?tab= with 400 VALIDATION_ERROR", async () => {
      const { agent } = await signup();

      const res = await agent.get("/recipes");

      expect(res.status).to.equal(400);
      expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });

    it("returns an empty catalog when no recipe exists yet", async () => {
      const { agent } = await signup();

      const res = await agent.get("/recipes").query({ tab: "publique" });

      expect(res.status).to.equal(200);
      expect(res.body).to.deep.equal([]);
    });

    it("filters the catalog by name when ?search= is given", async () => {
      const { agent, profileId } = await signup();
      await prisma.recipe.create({
        data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });
      await prisma.recipe.create({
        data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC", portions: 6 },
      });

      const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });

      expect(res.status).to.equal(200);
      expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Ratatouille"]);
    });

    it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
      const { agent, profileId } = await signup();
      const { profileId: otherId } = await signup();
      await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId, portions: 4 } });
      await prisma.recipe.create({
        data: { name: "Pas la mienne", authorId: otherId, portions: 4 },
      });

      const res = await agent.get("/recipes").query({ tab: "perso" });

      expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["La mienne"]);
    });

    it("foyer tab only returns HOUSE recipes authored within the viewer's current house", async () => {
      const { agent, profileId } = await signup();
      const houseRes = await agent.post("/house").send({ name: "Chez moi" });
      const { profileId: otherId } = await signup();
      const otherHouseRes = await request.agent(app).post("/house").send({ name: "Chez un autre" });

      await prisma.recipe.create({
        data: {
          name: "Recette du foyer",
          authorId: profileId,
          visibility: "HOUSE",
          authorHouseId: houseRes.body.id,
          portions: 4,
        },
      });
      await prisma.recipe.create({
        data: {
          name: "Recette d'un autre foyer",
          authorId: otherId,
          visibility: "HOUSE",
          authorHouseId: otherHouseRes.body.id,
          portions: 4,
        },
      });

      const res = await agent.get("/recipes").query({ tab: "foyer" });

      expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Recette du foyer"]);
    });

    it("foyer tab is empty when the viewer has no household", async () => {
      const { agent } = await signup();

      const res = await agent.get("/recipes").query({ tab: "foyer" });

      expect(res.status).to.equal(200);
      expect(res.body).to.deep.equal([]);
    });

    it("favoris tab only returns recipes the viewer has favorited", async () => {
      const { agent, profileId } = await signup();
      const favorited = await prisma.recipe.create({
        data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });
      await prisma.recipe.create({
        data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });
      await agent.post(`/recipes/${favorited.id}/favorite`);

      const res = await agent.get("/recipes").query({ tab: "favoris" });

      expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Favorite"]);
    });

    it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
      const { agent } = await signup();
      const { profileId: otherId } = await signup();
      await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId, portions: 4 } });

      const res = await agent.get("/recipes").query({ tab: "publique" });

      expect(res.body).to.deep.equal([]);
    });

    describe("source visibility", () => {
      afterEach(() => {
        clearRecipeSources();
      });

      /** Registers+syncs a throwaway adapter and returns the `Source` row `syncRecipeSources` created for it. */
      async function registerAndSyncSource(key: string) {
        registerRecipeSource(buildFakeAdapter(key, key));
        await syncRecipeSources(prisma);
        return prisma.source.findUniqueOrThrow({ where: { key } });
      }

      it("always shows a manually-authored recipe, even for a household with nothing enabled", async () => {
        const { agent, profileId } = await signup();
        await agent.post("/house").send({ name: "Chez moi" });
        await prisma.recipe.create({
          data: { name: "Maison", authorId: profileId, visibility: "PUBLIC", portions: 4 },
        });

        const res = await agent.get("/recipes").query({ tab: "publique" });
        expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Maison"]);
      });

      it("hides a sourced recipe until the viewer's household enables that source, then shows it", async () => {
        const { agent, profileId } = await signup();
        await agent.post("/house").send({ name: "Chez moi" });
        const source = await registerAndSyncSource("fakeSource");
        await prisma.recipe.create({
          data: {
            name: "Importée",
            authorId: profileId,
            visibility: "PUBLIC",
            portions: 4,
            sourceId: source.id,
            externalId: "1",
          },
        });

        const hidden = await agent.get("/recipes").query({ tab: "publique" });
        expect(hidden.body).to.deep.equal([]);

        const patchRes = await agent
          .patch("/house/current/sources")
          .send({ sourceIds: [source.id] });
        expect(patchRes.status).to.equal(200);

        const visible = await agent.get("/recipes").query({ tab: "publique" });
        expect(visible.body.map((r: { name: string }) => r.name)).to.deep.equal(["Importée"]);
      });

      it("hides a disabled-source recipe in every tab, including the viewer's own perso and favoris", async () => {
        const { agent, profileId } = await signup();
        await agent.post("/house").send({ name: "Chez moi" });
        const source = await registerAndSyncSource("fakeSource");
        const recipe = await prisma.recipe.create({
          data: {
            name: "Ma recette importée",
            authorId: profileId,
            portions: 4,
            sourceId: source.id,
            externalId: "1",
          },
        });
        await prisma.recipeFavorite.create({
          data: { userProfileId: profileId, recipeId: recipe.id },
        });

        expect((await agent.get("/recipes").query({ tab: "perso" })).body).to.deep.equal([]);
        expect((await agent.get("/recipes").query({ tab: "favoris" })).body).to.deep.equal([]);

        await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });

        const persoAfter = await agent.get("/recipes").query({ tab: "perso" });
        expect(persoAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
          "Ma recette importée",
        ]);
        const favorisAfter = await agent.get("/recipes").query({ tab: "favoris" });
        expect(favorisAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
          "Ma recette importée",
        ]);
      });

      it("hides every sourced recipe for a viewer with no household at all", async () => {
        const { agent, profileId } = await signup();
        const source = await registerAndSyncSource("fakeSource");
        await prisma.recipe.create({
          data: {
            name: "Importée",
            authorId: profileId,
            visibility: "PUBLIC",
            portions: 4,
            sourceId: source.id,
            externalId: "1",
          },
        });

        const res = await agent.get("/recipes").query({ tab: "publique" });
        expect(res.body).to.deep.equal([]);
      });
    });
  });

  describe("POST /recipes", () => {
    it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const oeuf = await ingredientId("egg");
      const piece = await unitId("piece");
      const vegetarien = await prisma.diet.findFirstOrThrow({
        where: { key: "vegetarian" },
      });

      const res = await agent.post("/recipes").send({
        name: "Omelette provençale",
        description: "Rapide et savoureuse",
        portions: 2,
        dietIds: [vegetarien.id],
        ingredients: [
          { ingredientId: tomate, quantity: 2, unitId: piece },
          { ingredientId: oeuf, quantity: 3, unitId: piece },
        ],
        steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
      });

      expect(res.status).to.equal(201);
      expect(res.body.name).to.equal("Omelette provençale");
      expect(res.body.portions).to.equal(2);
      expect(res.body.ingredients).to.have.length(2);
      expect(res.body.ingredients[0].unit.key).to.equal("piece");
      expect(
        res.body.steps.map((s: { description: string; order: number }) => s.order),
      ).to.deep.equal([0, 1]);
      // Allergens aggregated across ingredients — "Œuf" carries "Œufs".
      expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
      expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
    });

    it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
      const { agent } = await signup();
      const houseRes = await agent.post("/house").send({ name: "Chez moi" });
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });

      expect(res.body.visibility).to.equal("PERSONAL");
      // authorHouseId isn't in the API response, but the "foyer" tab
      // proves it was stamped — a HOUSE recipe created next should show up.
      const houseRecipe = await agent.post("/recipes").send({
        name: "Foyer",
        visibility: "HOUSE",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });
      const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
      expect(foyerRes.body.map((r: { id: number }) => r.id)).to.include(houseRecipe.body.id);
      expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
    });

    it("auto-detects a step's technique from its description and persists it", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const simmer = await techStepId("simmer");
      const description = "Faire mijoter à feu doux pendant 30 minutes";

      const res = await agent.post("/recipes").send({
        name: "Ragoût",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description }],
      });

      expect(res.status).to.equal(201);
      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
      expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]);
      // Exposed via StepView too — the whole point of persisting start/end
      // (see tech-step-matcher.ts's matchTechStepSpans) is that the API
      // response itself carries exactly what to highlight, not just the id.
      const resStep = res.body.steps[0];
      expect(resStep.techSteps).to.have.length(1);
      expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
      const { start, end } = resStep.techSteps[0];
      expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
    });

    it("leaves a step's technique sequence empty when its description matches no known technique", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Servir immédiatement" }],
      });

      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
      expect(await stepTechStepIds(step.id)).to.deep.equal([]);
    });

    it("detects each step's technique(s) independently, preserving order", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const simmer = await techStepId("simmer");
      const chop = await techStepId("chop");

      const res = await agent.post("/recipes").send({
        name: "Ragoût",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [
          { description: "Hacher les oignons" },
          { description: "Servir immédiatement" },
          { description: "Faire mijoter à feu doux" },
        ],
      });

      expect(res.status).to.equal(201);
      const steps = await prisma.step.findMany({
        where: { recipeId: res.body.id },
        orderBy: { order: "asc" },
      });
      expect(await Promise.all(steps.map((s) => stepTechStepIds(s.id)))).to.deep.equal([
        [chop],
        [],
        [simmer],
      ]);
    });

    it("picks the more specific technique end-to-end when a description matches more than one", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const bake = await techStepId("bake");

      const res = await agent.post("/recipes").send({
        name: "Gratin",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        // Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
        steps: [{ description: "Cuire au four pendant 30 minutes" }],
      });

      expect(res.status).to.equal(201);
      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
      expect(await stepTechStepIds(step.id)).to.deep.equal([bake]);
    });

    it("detects a sequence of several distinct techniques within a single step, in reading order", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const preheat = await techStepId("preheat");
      const melt = await techStepId("melt");
      const description = "Préchauffer la poêle, puis faire fondre le beurre";

      const res = await agent.post("/recipes").send({
        name: "Poêlée",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        // The case that motivated the sequence model: one instruction, two techniques.
        steps: [{ description }],
      });

      expect(res.status).to.equal(201);
      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
      expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]);
      // Each entry's span, sliced back out of the description, is exactly
      // the word(s) that triggered that particular match.
      const resTechSteps = res.body.steps[0].techSteps;
      expect(resTechSteps.map((t: { techStep: { key: string } }) => t.techStep.key)).to.deep.equal([
        "preheat",
        "melt",
      ]);
      expect(description.slice(resTechSteps[0].start, resTechSteps[0].end).toLowerCase()).to.equal(
        "préchauffer",
      );
      expect(description.slice(resTechSteps[1].start, resTechSteps[1].end).toLowerCase()).to.equal(
        "faire fondre",
      );
    });

    it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
      const { agent } = await signup();
      const piece = await unitId("piece");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
    });

    it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
    });

    it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [999_999],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
    });

    it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
      const { agent } = await signup();

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(400);
      expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });

    it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
      // `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — a
      // manual creation can't reach this via the web UI (`IngredientPicker`
      // hides an already-picked ingredient), but nothing stops a raw
      // request (or a source import, whose lines aren't deduplicated) from
      // sending it — must fail cleanly instead of crashing on the DB's
      // unique-constraint violation.
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");

      const res = await agent.post("/recipes").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [
          { ingredientId: tomate, quantity: 1, unitId: piece },
          { ingredientId: tomate, quantity: 2, unitId: piece },
        ],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(400);
      expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });

    it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const basePayload = {
        name: "Test",
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      };

      const missing = await agent.post("/recipes").send(basePayload);
      const zero = await agent.post("/recipes").send({ ...basePayload, portions: 0 });

      expect(missing.status).to.equal(400);
      expect(missing.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
      expect(zero.status).to.equal(400);
      expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });
  });

  describe("GET /recipes/:id", () => {
    it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
      const { agent } = await signup();

      const res = await agent.get("/recipes/999999");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });

    it("returns the full recipe detail", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const created = await agent.post("/recipes").send({
        name: "Salade",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Couper" }],
      });

      const res = await agent.get(`/recipes/${created.body.id}`);

      expect(res.status).to.equal(200);
      expect(res.body.name).to.equal("Salade");
      expect(res.body.portions).to.equal(4);
      expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
      expect(res.body.ingredients[0].unit.key).to.equal("piece");
      expect(res.body.isFavorite).to.equal(false);
    });

    it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
      const { agent } = await signup();
      const { profileId: otherId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Secrète", authorId: otherId, portions: 4 },
      });

      const res = await agent.get(`/recipes/${recipe.id}`);

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });

    it("returns 200 for a PUBLIC recipe belonging to someone else", async () => {
      const { agent } = await signup();
      const { profileId: otherId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC", portions: 4 },
      });

      const res = await agent.get(`/recipes/${recipe.id}`);

      expect(res.status).to.equal(200);
    });

    it("returns 200 for a HOUSE recipe shared with the viewer's household, 404 otherwise", async () => {
      const { agent } = await signup();
      const houseRes = await agent.post("/house").send({ name: "Chez moi" });
      const { profileId: otherId } = await signup();
      const inHouse = await prisma.recipe.create({
        data: {
          name: "Du foyer",
          authorId: otherId,
          visibility: "HOUSE",
          authorHouseId: houseRes.body.id,
          portions: 4,
        },
      });
      const otherHouseId = (
        await prisma.house.create({
          data: { name: "Autre", adminId: otherId, inviteCode: "TESTHOUS" },
        })
      ).id;
      const outsideHouse = await prisma.recipe.create({
        data: {
          name: "D'un autre foyer",
          authorId: otherId,
          visibility: "HOUSE",
          authorHouseId: otherHouseId,
          portions: 4,
        },
      });

      const inHouseRes = await agent.get(`/recipes/${inHouse.id}`);
      const outsideHouseRes = await agent.get(`/recipes/${outsideHouse.id}`);

      expect(inHouseRes.status).to.equal(200);
      expect(outsideHouseRes.status).to.equal(404);
    });
  });

  describe("PATCH /recipes/:id", () => {
    it("replaces the recipe's whole content", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const oignon = await ingredientId("onion");
      const piece = await unitId("piece");
      const gram = await unitId("gram");
      const created = await agent.post("/recipes").send({
        name: "Salade",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Couper" }],
      });

      const res = await agent.patch(`/recipes/${created.body.id}`).send({
        name: "Salade composée",
        portions: 6,
        visibility: "PUBLIC",
        dietIds: [],
        ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
        steps: [{ description: "Émincer" }, { description: "Mélanger" }],
      });

      expect(res.status).to.equal(200);
      expect(res.body.name).to.equal("Salade composée");
      expect(res.body.portions).to.equal(6);
      expect(res.body.visibility).to.equal("PUBLIC");
      expect(res.body.ingredients).to.have.length(1);
      expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
      expect(res.body.ingredients[0].unit.key).to.equal("gram");
      expect(res.body.steps).to.have.length(2);
    });

    it("recomputes each replaced step's technique sequence", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const mince = await techStepId("mince");
      const created = await agent.post("/recipes").send({
        name: "Salade",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Servir immédiatement" }],
      });

      const res = await agent.patch(`/recipes/${created.body.id}`).send({
        name: "Salade",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Émincer les tomates" }],
      });

      expect(res.status).to.equal(200);
      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } });
      expect(await stepTechStepIds(step.id)).to.deep.equal([mince]);
    });

    it("recomputes techniques from scratch on every edit — modifying, adding, and removing a step all take effect, nothing stale survives", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const chop = await techStepId("chop");
      const _mince = await techStepId("mince");
      const _melt = await techStepId("melt");
      const simmer = await techStepId("simmer");
      const bake = await techStepId("bake");

      const created = await agent.post("/recipes").send({
        name: "Ragoût",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [
          { description: "Hacher les oignons" }, // chop
          { description: "Faire mijoter à feu doux" }, // simmer
          { description: "Cuire au four" }, // bake
        ],
      });
      expect(created.status).to.equal(201);
      const originalStepIds = (
        await prisma.step.findMany({ where: { recipeId: created.body.id } })
      ).map((s) => s.id);
      expect(originalStepIds).to.have.length(3);
      // Sanity check before the edit — each original step really did get a
      // techStepId chop/simmer/bake (proves the later assertions are
      // actually about recomputation, not about it never having matched).
      expect((await Promise.all(originalStepIds.map(stepTechStepIds))).flat().sort()).to.deep.equal(
        [chop, simmer, bake].sort(),
      );

      // Edit: step 1's description changes (chop -> mince), a brand new
      // step 2 is added (-> melt), and the old steps 2/3 (simmer/bake) are
      // dropped entirely — the three cases the recompute guarantee has to
      // cover (see StepTechStep's schema doc comment).
      const editedDescription = "Émincer les tomates";
      const addedDescription = "Faire fondre le beurre";
      const res = await agent.patch(`/recipes/${created.body.id}`).send({
        name: "Ragoût",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: editedDescription }, { description: addedDescription }],
      });

      expect(res.status).to.equal(200);
      expect(res.body.steps).to.have.length(2);
      expect(
        res.body.steps.map((s: { techSteps: { techStep: { key: string } }[] }) =>
          s.techSteps.map((t) => t.techStep.key),
        ),
      ).to.deep.equal([["mince"], ["melt"]]);
      // Modified step's span reflects the NEW text, not a stale one from
      // "Hacher les oignons" (which doesn't even contain "émincer").
      const editedTechStep = res.body.steps[0].techSteps[0];
      expect(
        editedDescription.slice(editedTechStep.start, editedTechStep.end).toLowerCase(),
      ).to.equal("émincer");
      const addedTechStep = res.body.steps[1].techSteps[0];
      expect(addedDescription.slice(addedTechStep.start, addedTechStep.end).toLowerCase()).to.equal(
        "faire fondre",
      );

      // The dropped steps' old rows are actually gone (cascade), not just
      // invisible in the response — confirms "delete" really deletes rather
      // than orphaning StepTechStep rows nothing references any more.
      const remainingSteps = await prisma.step.findMany({ where: { recipeId: created.body.id } });
      expect(remainingSteps).to.have.length(2);
      const orphanedTechSteps = await prisma.stepTechStep.findMany({
        where: { stepId: { in: originalStepIds } },
      });
      expect(orphanedTechSteps).to.deep.equal([]);
    });

    it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");

      const res = await agent.patch("/recipes/999999").send({
        name: "Test",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });

    it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
      const { agent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const created = await agent.post("/recipes").send({
        name: "Salade",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Couper" }],
      });

      const res = await agent.patch(`/recipes/${created.body.id}`).send({
        name: "Salade",
        portions: 4,
        dietIds: [999_999],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Couper" }],
      });

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
    });

    it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
      const { profileId } = await signup();
      const { agent: otherAgent } = await signup();
      const tomate = await ingredientId("tomato");
      const piece = await unitId("piece");
      const recipe = await prisma.recipe.create({
        data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });

      const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
        name: "Hack",
        portions: 4,
        dietIds: [],
        ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
        steps: [{ description: "Étape" }],
      });

      expect(res.status).to.equal(403);
      expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
    });
  });

  describe("DELETE /recipes/:id", () => {
    it("deletes a recipe not referenced by any planning item", async () => {
      const { agent, profileId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "À supprimer", authorId: profileId, portions: 4 },
      });

      const res = await agent.delete(`/recipes/${recipe.id}`);
      expect(res.status).to.equal(204);

      const getRes = await agent.get(`/recipes/${recipe.id}`);
      expect(getRes.status).to.equal(404);
    });

    it("rejects deleting a recipe still used by a planning item with 409 RECIPE_IN_USE", async () => {
      const { agent, profileId } = await signup();
      const houseRes = await agent.post("/house").send({ name: "Chez moi" });
      const recipe = await prisma.recipe.create({
        data: { name: "Ratatouille", authorId: profileId, portions: 4 },
      });
      const planning = await prisma.planning.create({
        data: {
          houseId: houseRes.body.id,
          startDate: new Date(Date.UTC(2026, 0, 1)),
          finishDate: new Date(Date.UTC(2026, 0, 7)),
        },
      });
      await prisma.planningItem.create({
        data: {
          planningId: planning.id,
          weekDay: "lundi",
          meal: "diner",
          recipeId: recipe.id,
          portions: 4,
        },
      });

      const res = await agent.delete(`/recipes/${recipe.id}`);

      expect(res.status).to.equal(409);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_IN_USE);
    });

    it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
      const { agent } = await signup();

      const res = await agent.delete("/recipes/999999");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });

    it("rejects deleting someone else's recipe with 403 NOT_RECIPE_AUTHOR", async () => {
      const { profileId } = await signup();
      const { agent: otherAgent } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });

      const res = await otherAgent.delete(`/recipes/${recipe.id}`);

      expect(res.status).to.equal(403);
      expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
    });
  });

  describe("POST/DELETE /recipes/:id/favorite", () => {
    it("adds and removes a recipe from the viewer's favorites", async () => {
      const { agent, profileId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });

      const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
      expect(addRes.status).to.equal(204);
      expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(true);

      const removeRes = await agent.delete(`/recipes/${recipe.id}/favorite`);
      expect(removeRes.status).to.equal(204);
      expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(false);
    });

    it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
      const { agent, profileId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
      });

      await agent.post(`/recipes/${recipe.id}/favorite`);
      const res = await agent.post(`/recipes/${recipe.id}/favorite`);

      expect(res.status).to.equal(204);
    });

    it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
      const { agent } = await signup();
      const { profileId: otherId } = await signup();
      const recipe = await prisma.recipe.create({
        data: { name: "Secrète", authorId: otherId, portions: 4 },
      });

      const res = await agent.post(`/recipes/${recipe.id}/favorite`);

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });
  });
});
