import type {
  CookingPhaseView,
  CookingTaskIngredientView,
  CookingTaskView,
  IngredientView,
  TechStepView,
  UnitView,
} from "@batch-cooking/shared";
import { expect } from "chai";
import {
  type OptimizerRecipeInput,
  type OptimizerStepInput,
  type OptimizerTechStepInput,
  optimizeCookingPlan,
} from "../../src/lib/recipe-matching/cooking-optimizer.js";

/**
 * Pure unit tests for the batch-cooking optimizer — no database, fixtures
 * hand-built (they're technique/ingredient reference shapes, not the
 * personal data `@faker-js/faker` covers).
 *
 * The suite is black-box: everything goes through `optimizeCookingPlan`
 * (the only public entry point), never the private helpers, so the tests
 * stay valid across any internal refactor that keeps the contract.
 */
describe("cooking-optimizer", () => {
  // --- Fixture builders ----------------------------------------------------

  /** Minimal `TechStepView` — only `key` drives the optimizer, `id` is passed through. */
  function tech(key: string): TechStepView {
    // Stable pseudo-id per key so two fixtures naming the same technique
    // get the same `id` without a lookup table.
    let hash = 0;
    for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 100000;
    return { id: hash, key };
  }

  /** Minimal `IngredientView` — the optimizer only reads `id`/`key`. */
  function ingredient(id: number, key: string): IngredientView {
    return {
      id,
      key,
      icon: "VEGETABLE",
      category: "freshProduce",
      subcategory: "vegetables",
      reproducible: false,
      allergens: [],
      diets: [],
    };
  }

  /** Reference ingredient catalog shared by the fixtures below. */
  const ING = {
    onion: ingredient(1, "onion"),
    garlic: ingredient(2, "garlic"),
    carrot: ingredient(3, "carrot"),
    potato: ingredient(4, "potato"),
    tomato: ingredient(5, "tomato"),
    beef: ingredient(6, "beef"),
    chicken: ingredient(7, "chicken"),
    parsley: ingredient(8, "parsley"),
    cream: ingredient(9, "cream"),
    flour: ingredient(10, "flour"),
    butter: ingredient(11, "butter"),
    rice: ingredient(12, "rice"),
    lentils: ingredient(13, "lentils"),
    egg: ingredient(14, "egg"),
    cheese: ingredient(15, "cheese"),
  } as const;

  const GRAM: UnitView = { id: 1, key: "gram", type: "MASS", toBaseFactor: 1 };
  const PIECE: UnitView = { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 };
  const KILOGRAM: UnitView = { id: 3, key: "kilogram", type: "MASS", toBaseFactor: 1000 };

  function line(
    ing: IngredientView,
    quantity: number | null = null,
    unit: UnitView | null = null,
  ): CookingTaskIngredientView {
    return { ingredient: ing, quantity, unit };
  }

  function utensil(id: number, key: string) {
    return { id, key };
  }

  /** One technique clause of a step. */
  function clause(
    techKey: string,
    ingredients: CookingTaskIngredientView[] = [],
    utensils: { id: number; key: string }[] = [],
  ): OptimizerTechStepInput {
    return { techStep: tech(techKey), order: 0, ingredients, utensils };
  }

  /** A step described by an ordered list of technique clauses. */
  function step(
    stepId: number,
    order: number,
    description: string,
    clauses: OptimizerTechStepInput[],
  ): OptimizerStepInput {
    return {
      stepId,
      order,
      description,
      techSteps: clauses.map((c, i) => ({ ...c, order: i })),
    };
  }

  /** Shorthand for a single-technique step. */
  function simpleStep(
    stepId: number,
    order: number,
    description: string,
    techKey: string,
    ingredients: CookingTaskIngredientView[] = [],
  ): OptimizerStepInput {
    return step(stepId, order, description, [clause(techKey, ingredients)]);
  }

  function recipe(
    recipeId: number,
    name: string,
    steps: OptimizerStepInput[],
    portions = 4,
    recipePortions = 4,
  ): OptimizerRecipeInput {
    return { recipeId, name, portions, recipePortions, steps };
  }

  // --- Assertion helpers -------------------------------------------------

  const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
    plan.phases.flatMap((phase) => phase.tasks);

  const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
    allTasks(plan).filter((task) => task.kind === "merged-prep");

  const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
    allTasks(plan).filter((task) => task.kind === "step");

  /**
   * Structural invariant: every input step is accounted for **exactly
   * once** — either it survived as its own `step` task, or it was absorbed
   * into a `merged-prep` pool (its text shows in that pool's
   * `originalSteps`), never both and never neither. Also: no `step` task id
   * is emitted twice, and every `step` task maps back to a real input step.
   */
  function assertEveryStepAccountedForOnce(
    recipes: OptimizerRecipeInput[],
    plan: { phases: CookingPhaseView[] },
  ) {
    const stepTaskIds = new Set<string>();
    for (const task of stepTasks(plan)) {
      expect(stepTaskIds.has(task.id), `duplicate step task ${task.id}`).to.equal(false);
      stepTaskIds.add(task.id);
    }

    const absorbedTexts = new Set(
      mergedTasks(plan).flatMap((task) => task.originalSteps.map((s) => s.description)),
    );

    recipes.forEach((r, recipeIndex) => {
      for (const s of r.steps) {
        const asStepTask = stepTaskIds.has(`step:${recipeIndex}:${s.stepId}`);
        const asAbsorbed = absorbedTexts.has(s.description);
        expect(
          asStepTask !== asAbsorbed,
          `step "${s.description}" (recipe ${recipeIndex}) should appear exactly once (stepTask=${asStepTask}, absorbed=${asAbsorbed})`,
        ).to.equal(true);
      }
    });

    // Every emitted step task points at a real input step.
    const validIds = new Set(
      recipes.flatMap((r, i) => r.steps.map((s) => `step:${i}:${s.stepId}`)),
    );
    for (const id of stepTaskIds) {
      expect(validIds.has(id), `step task ${id} has no matching input step`).to.equal(true);
    }
  }

  /**
   * Task ids are globally unique across the plan; within any single phase,
   * no id (task or background) collides. A background line legitimately
   * repeats across consecutive phases (a long cook shown as still running),
   * so background ids are only checked for uniqueness *within* a phase.
   */
  function assertUniqueIds(plan: { phases: CookingPhaseView[] }) {
    const taskIds: string[] = [];
    for (const phase of plan.phases) {
      const perPhase = new Set<string>();
      for (const task of phase.tasks) {
        taskIds.push(task.id);
        expect(
          perPhase.has(task.id),
          `duplicate id ${task.id} within phase ${phase.index}`,
        ).to.equal(false);
        perPhase.add(task.id);
      }
      for (const bg of phase.background) {
        expect(perPhase.has(bg.id), `duplicate id ${bg.id} within phase ${phase.index}`).to.equal(
          false,
        );
        perPhase.add(bg.id);
      }
    }
    expect(taskIds.length, "task ids should be globally unique").to.equal(new Set(taskIds).size);
  }

  /** Phase indices are 0..n-1 and match array position. */
  function assertPhaseIndices(plan: { phases: CookingPhaseView[] }) {
    plan.phases.forEach((phase, i) => {
      expect(phase.index).to.equal(i);
    });
  }

  // =====================================================================
  //  Basic contract
  // =====================================================================
  describe("basic contract", () => {
    it("returns an empty plan for no recipes", () => {
      expect(optimizeCookingPlan([])).to.deep.equal({ recipes: [], phases: [] });
    });

    it("returns an empty plan for recipes that have no steps", () => {
      const plan = optimizeCookingPlan([recipe(1, "Vide", []), recipe(2, "Vide aussi", [])]);
      expect(plan.phases).to.deep.equal([]);
      expect(plan.recipes.map((r) => r.name)).to.deep.equal(["Vide", "Vide aussi"]);
    });

    it("keeps a single recipe's steps in order across phases", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Omelette", [
          simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]),
          simpleStep(2, 1, "Cuire", "cook"),
          simpleStep(3, 2, "Servir", "plate"),
        ]),
      ]);
      const descriptions = stepTasks(plan).map((t) => t.description);
      expect(descriptions).to.deep.equal(["Battre les œufs", "Cuire", "Servir"]);
      assertEveryStepAccountedForOnce(
        [
          recipe(1, "Omelette", [
            simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]),
            simpleStep(2, 1, "Cuire", "cook"),
            simpleStep(3, 2, "Servir", "plate"),
          ]),
        ],
        plan,
      );
    });

    it("exposes each planned recipe in the legend, de-duplicating an exact repeat", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4),
        recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4),
        recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 8, 4),
      ]);
      expect(plan.recipes).to.deep.equal([
        { recipeId: 1, name: "A", portions: 4 },
        { recipeId: 1, name: "A", portions: 8 },
      ]);
    });

    it("is deterministic — identical input yields byte-identical output", () => {
      const build = () => [
        recipe(1, "Soupe", [
          simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 2, PIECE)]),
          simpleStep(2, 1, "Mijoter", "simmer"),
        ]),
        recipe(2, "Tarte", [
          simpleStep(3, 0, "Émincer l'oignon", "chop", [line(ING.onion, 3, PIECE)]),
          simpleStep(4, 1, "Enfourner", "bake"),
        ]),
      ];
      const a = optimizeCookingPlan(build());
      const b = optimizeCookingPlan(build());
      expect(JSON.stringify(a)).to.equal(JSON.stringify(b));
    });
  });

  // =====================================================================
  //  Technique classification (attention + prep)
  // =====================================================================
  describe("technique classification", () => {
    it("pulls a SETUP step (preheat / boil) into the mise-en-place phase, out of recipe order", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Gratin", [
          simpleStep(1, 0, "Éplucher les pommes de terre", "peel", [line(ING.potato, 800, GRAM)]),
          simpleStep(2, 1, "Préchauffer le four à 200°C", "preheat"),
          simpleStep(3, 2, "Assembler", "coat"),
          simpleStep(4, 3, "Enfourner", "bake"),
        ]),
      ]);

      const mise = plan.phases[0];
      expect(mise?.kind).to.equal("mise-en-place");
      const miseDescriptions = mise?.tasks.map((t) => t.description) ?? [];
      expect(miseDescriptions).to.include("Préchauffer le four à 200°C");
      expect(miseDescriptions).to.include("Éplucher les pommes de terre");
      // ...and it does not reappear later.
      const laterDescriptions = plan.phases
        .slice(1)
        .flatMap((p) => p.tasks.map((t) => t.description));
      expect(laterDescriptions).to.not.include("Préchauffer le four à 200°C");
    });

    it("treats a step whose LAST technique is passive as a background cook", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Bœuf braisé", [
          simpleStep(1, 0, "Saisir la viande", "brown", [line(ING.beef, 1, KILOGRAM)]),
          step(2, 1, "Ajouter le bouillon et laisser braiser 2h", [
            clause("deglaze"),
            clause("braise"),
          ]),
          simpleStep(3, 2, "Dresser", "plate"),
        ]),
        recipe(2, "Salade", [
          simpleStep(4, 0, "Laver", "mix"),
          simpleStep(5, 1, "Assaisonner", "season"),
          simpleStep(6, 2, "Servir", "plate"),
        ]),
      ]);

      const backgrounds = plan.phases.flatMap((p) => p.background.map((b) => b.description));
      expect(backgrounds).to.include("Ajouter le bouillon et laisser braiser 2h");
    });

    it("does NOT treat a step as prep when it mixes a cut with a cooking technique", () => {
      const recipes = [
        recipe(1, "Poêlée A", [
          step(1, 0, "Émincer puis faire revenir l'oignon", [
            clause("chop", [line(ING.onion, 2, PIECE)]),
            clause("panFry", [line(ING.onion, 2, PIECE)]),
          ]),
        ]),
        recipe(2, "Poêlée B", [
          step(2, 0, "Émincer puis faire revenir l'oignon", [
            clause("chop", [line(ING.onion, 2, PIECE)]),
            clause("panFry", [line(ING.onion, 2, PIECE)]),
          ]),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);

      // Identical text in two recipes, but the panFry keeps each a cooking
      // step — no pooling, both survive as their own step task.
      expect(mergedTasks(plan)).to.have.lengthOf(0);
      expect(stepTasks(plan)).to.have.lengthOf(2);
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("classifies a step with no detected technique as ordinary active work", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Libre", [
          { stepId: 1, order: 0, description: "Faire quelque chose", techSteps: [] },
          simpleStep(2, 1, "Puis autre chose", "mix"),
        ]),
      ]);
      // No technique → not prep, not passive, not setup: it flows through the
      // cooking phases like any active step, and carries a null technique.
      const task = stepTasks(plan).find((t) => t.description === "Faire quelque chose");
      expect(task, "the no-technique step should still be scheduled").to.not.equal(undefined);
      expect(task?.technique).to.equal(null);
      expect(plan.phases.some((p) => p.kind === "mise-en-place")).to.equal(false);
    });
  });

  // =====================================================================
  //  Prep pooling
  // =====================================================================
  describe("prep pooling", () => {
    it("pools identical prep from two recipes into one merged-prep task with summed quantity", () => {
      const recipes = [
        recipe(1, "Soupe", [
          simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
          simpleStep(2, 1, "Faire mijoter", "simmer"),
        ]),
        recipe(2, "Tarte", [
          simpleStep(3, 0, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]),
          simpleStep(4, 1, "Enfourner", "bake"),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);

      const merged = mergedTasks(plan);
      expect(merged).to.have.lengthOf(1);
      expect(merged[0]?.technique?.key).to.equal("chop");
      expect(merged[0]?.description).to.equal(null);
      expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]);
      expect(merged[0]?.ingredients[0]?.quantity).to.equal(5);
      expect(merged[0]?.ingredients[0]?.unit?.key).to.equal("piece");
      expect(merged[0]?.id).to.equal("prep:chop:onion");
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("pools across THREE recipes and lists every source recipe once", () => {
      const recipes = [1, 2, 3].map((id) =>
        recipe(id, `Recette ${id}`, [
          simpleStep(id * 10, 0, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]),
          simpleStep(id * 10 + 1, 1, "Cuire", "cook"),
        ]),
      );
      const plan = optimizeCookingPlan(recipes);

      const merged = mergedTasks(plan);
      expect(merged).to.have.lengthOf(1);
      expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.deep.equal([1, 2, 3]);
      expect(merged[0]?.ingredients[0]?.quantity).to.equal(6);
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("keeps prep that only one recipe needs as its own step task (no merge)", () => {
      const recipes = [
        recipe(1, "Curry", [
          simpleStep(1, 0, "Râper les carottes", "chop", [line(ING.carrot, 200, GRAM)]),
          simpleStep(2, 1, "Cuire", "cook"),
        ]),
        recipe(2, "Gratin", [simpleStep(3, 0, "Cuire au four", "bake")]),
      ];
      const plan = optimizeCookingPlan(recipes);

      expect(mergedTasks(plan)).to.have.lengthOf(0);
      const prepTask = allTasks(plan).find((t) => t.description === "Râper les carottes");
      expect(prepTask?.kind).to.equal("step");
      expect(plan.phases[0]?.kind).to.equal("mise-en-place");
      expect(plan.phases[0]?.tasks).to.include(prepTask);
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("does not pool the same ingredient cut with two DIFFERENT techniques", () => {
      const recipes = [
        recipe(1, "A", [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])]),
        recipe(2, "B", [
          simpleStep(2, 0, "Tailler l'oignon en brunoise", "brunoise", [line(ING.onion, 1, PIECE)]),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);
      expect(mergedTasks(plan)).to.have.lengthOf(0);
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("pools a multi-ingredient prep step only when the whole cut signature matches", () => {
      const matching = [
        recipe(1, "Mirepoix A", [
          step(1, 0, "Tailler oignon et carotte", [
            clause("chop", [line(ING.onion, 1, PIECE)]),
            clause("chop", [line(ING.carrot, 1, PIECE)]),
          ]),
        ]),
        recipe(2, "Mirepoix B", [
          step(2, 0, "Tailler oignon et carotte", [
            clause("chop", [line(ING.onion, 2, PIECE)]),
            clause("chop", [line(ING.carrot, 2, PIECE)]),
          ]),
        ]),
      ];
      const plan = optimizeCookingPlan(matching);
      const merged = mergedTasks(plan);
      expect(merged).to.have.lengthOf(1);
      // Two ingredient lines, each summed within its own (ingredient, unit) group.
      const byKey = Object.fromEntries(
        (merged[0]?.ingredients ?? []).map((i) => [i.ingredient.key, i.quantity]),
      );
      expect(byKey).to.deep.equal({ carrot: 3, onion: 3 });
      assertEveryStepAccountedForOnce(matching, plan);
    });

    it("does NOT pool multi-ingredient prep steps whose signatures differ", () => {
      const recipes = [
        recipe(1, "A", [
          step(1, 0, "Tailler oignon et carotte", [
            clause("chop", [line(ING.onion, 1, PIECE)]),
            clause("chop", [line(ING.carrot, 1, PIECE)]),
          ]),
        ]),
        recipe(2, "B", [
          step(2, 0, "Tailler oignon et pomme de terre", [
            clause("chop", [line(ING.onion, 1, PIECE)]),
            clause("chop", [line(ING.potato, 1, PIECE)]),
          ]),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);
      expect(mergedTasks(plan)).to.have.lengthOf(0);
      assertEveryStepAccountedForOnce(recipes, plan);
    });

    it("drops the quantity when one recipe names the ingredient without a measurable amount", () => {
      const recipes = [
        recipe(1, "A", [
          simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
        ]),
        recipe(2, "B", [
          simpleStep(2, 0, "Émincer les oignons", "chop", [line(ING.onion, null, PIECE)]),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);
      const merged = mergedTasks(plan);
      expect(merged).to.have.lengthOf(1);
      expect(merged[0]?.ingredients[0]?.quantity).to.equal(null);
      expect(merged[0]?.ingredients[0]?.unit).to.equal(null);
    });

    it("unions the utensils of every pooled step, de-duplicated", () => {
      const recipes = [
        recipe(1, "A", [
          step(1, 0, "Émincer les oignons", [
            clause(
              "chop",
              [line(ING.onion, 1, PIECE)],
              [utensil(1, "knife"), utensil(2, "cuttingBoard")],
            ),
          ]),
        ]),
        recipe(2, "B", [
          step(2, 0, "Émincer les oignons", [
            clause(
              "chop",
              [line(ING.onion, 1, PIECE)],
              [utensil(1, "knife"), utensil(3, "mandoline")],
            ),
          ]),
        ]),
      ];
      const plan = optimizeCookingPlan(recipes);
      const merged = mergedTasks(plan);
      expect(merged[0]?.utensils.map((u) => u.key)).to.deep.equal([
        "cuttingBoard",
        "knife",
        "mandoline",
      ]);
    });

    it("still pools when the SAME recipe is planned twice at different portions", () => {
      const recipes = [
        recipe(
          1,
          "Bolo",
          [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])],
          4,
          4,
        ),
        recipe(
          1,
          "Bolo",
          [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])],
          8,
          4,
        ),
      ];
      const plan = optimizeCookingPlan(recipes);
      const merged = mergedTasks(plan);
      expect(merged).to.have.lengthOf(1);
      // 1 × (4/4) + 1 × (8/4) = 3
      expect(merged[0]?.ingredients[0]?.quantity).to.equal(3);
      expect(merged[0]?.sourceRecipes).to.have.lengthOf(2);
    });

    it("does not pool a prep clause that resolved no ingredient — it stays a standalone mise-en-place task", () => {
      const recipes = [
        recipe(1, "A", [simpleStep(1, 0, "Émincer finement", "chop", [])]),
        recipe(2, "B", [simpleStep(2, 0, "Émincer finement", "chop", [])]),
      ];
      const plan = optimizeCookingPlan(recipes);
      expect(mergedTasks(plan)).to.have.lengthOf(0);
      // Both are pure-prep with no ingredient → each lands in mise-en-place as its own task.
      expect(plan.phases[0]?.kind).to.equal("mise-en-place");
      expect(
        plan.phases[0]?.tasks.filter((t) => t.description === "Émincer finement"),
      ).to.have.lengthOf(2);
      assertEveryStepAccountedForOnce(recipes, plan);
    });
  });

  // =====================================================================
  //  Quantity scaling
  // =====================================================================
  describe("quantity scaling", () => {
    it("scales technique-clause quantities by portions / recipePortions", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Base", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 2, PIECE)])], 8, 4),
        recipe(
          2,
          "Autre",
          [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])],
          4,
          4,
        ),
      ]);
      const merged = mergedTasks(plan)[0];
      // (2 × 8/4) + (1 × 4/4) = 5
      expect(merged?.ingredients[0]?.quantity).to.equal(5);
    });

    it("falls back to 1× when recipePortions is zero or missing (bad data guard)", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 3, PIECE)])], 10, 0),
        recipe(2, "B", [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])], 4, 4),
      ]);
      const merged = mergedTasks(plan)[0];
      // recipe 1 scaled 1× (guard) → 3 ; recipe 2 → 1 ; total 4
      expect(merged?.ingredients[0]?.quantity).to.equal(4);
    });

    it("leaves a null-quantity clause null after scaling", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Saler", "season", [line(ING.onion, null, null)])], 8, 4),
      ]);
      const task = stepTasks(plan).find((t) => t.description === "Saler");
      expect(task?.ingredients[0]?.quantity).to.equal(null);
    });
  });

  // =====================================================================
  //  Phase scheduling & parallelism
  // =====================================================================
  describe("phase scheduling", () => {
    it("floats a passive cook into later phases' background while another recipe works, then clears it", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Ragoût", [
          simpleStep(1, 0, "Faire mijoter la viande", "simmer"),
          simpleStep(2, 1, "Rectifier l'assaisonnement", "season"),
          simpleStep(3, 2, "Dresser", "plate"),
        ]),
        recipe(2, "Salade", [
          simpleStep(4, 0, "Mélanger", "mix"),
          simpleStep(5, 1, "Assaisonner", "season"),
          simpleStep(6, 2, "Servir", "plate"),
        ]),
      ]);

      const cooking = plan.phases.filter((p) => p.kind !== "mise-en-place");
      const withSimmerBg = cooking.filter((p) =>
        p.background.some((b) => b.description === "Faire mijoter la viande"),
      );
      expect(withSimmerBg.length).to.be.greaterThan(0);
      expect(withSimmerBg[0]?.background[0]?.recipeId).to.equal(1);

      // Once recipe 1 pops its next step, the simmer stops being background.
      const lastPhase = plan.phases[plan.phases.length - 1];
      expect(
        lastPhase?.background.some((b) => b.description === "Faire mijoter la viande"),
      ).to.equal(false);
    });

    it("does not surface a single recipe's own passive cook as its own background (nothing else to do)", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Soupe", [
          simpleStep(1, 0, "Mijoter", "simmer"),
          simpleStep(2, 1, "Mixer", "mix"),
        ]),
      ]);
      const backgrounds = plan.phases.flatMap((p) => p.background);
      expect(backgrounds).to.deep.equal([]);
    });

    it("marks the last phase 'finishing' when it holds only plating", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]),
        recipe(2, "B", [simpleStep(3, 0, "Cuire", "cook"), simpleStep(4, 1, "Dresser", "plate")]),
      ]);
      const last = plan.phases[plan.phases.length - 1];
      expect(last?.kind).to.equal("finishing");
      expect(last?.tasks.every((t) => t.technique?.key === "plate")).to.equal(true);
    });

    it("keeps a mixed final phase as 'cooking', not 'finishing'", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Dresser", "plate")]),
        recipe(2, "B", [simpleStep(2, 0, "Étape 1", "mix"), simpleStep(3, 1, "Étape 2", "cook")]),
      ]);
      expect(plan.phases.every((p) => p.kind !== "finishing")).to.equal(true);
    });

    it("breaks the stall when every remaining recipe is holding on a passive cook", () => {
      // Both recipes: a passive step then a final step. After each starts its
      // passive cook they both 'hold' the next phase — the optimizer must
      // release the holds rather than loop or emit an empty phase.
      const plan = optimizeCookingPlan([
        recipe(1, "A", [
          simpleStep(1, 0, "Mijoter A", "simmer"),
          simpleStep(2, 1, "Finir A", "plate"),
        ]),
        recipe(2, "B", [
          simpleStep(3, 0, "Rôtir B", "roast"),
          simpleStep(4, 1, "Finir B", "plate"),
        ]),
      ]);
      expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true);
      assertEveryStepAccountedForOnce(
        [
          recipe(1, "A", [
            simpleStep(1, 0, "Mijoter A", "simmer"),
            simpleStep(2, 1, "Finir A", "plate"),
          ]),
          recipe(2, "B", [
            simpleStep(3, 0, "Rôtir B", "roast"),
            simpleStep(4, 1, "Finir B", "plate"),
          ]),
        ],
        plan,
      );
    });

    it("omits the mise-en-place phase entirely when there is no prep and no setup, and re-indexes from 0", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]),
      ]);
      expect(plan.phases[0]?.kind).to.not.equal("mise-en-place");
      assertPhaseIndices(plan);
    });

    it("starts the long cook first within a phase (passive tasks sorted ahead of active ones)", () => {
      const plan = optimizeCookingPlan([
        recipe(1, "Active", [simpleStep(1, 0, "Touiller", "mix")]),
        recipe(2, "Passive", [simpleStep(2, 0, "Mettre à mijoter", "simmer")]),
      ]);
      const firstCooking = plan.phases.find((p) => p.kind !== "mise-en-place");
      expect(firstCooking?.tasks[0]?.description).to.equal("Mettre à mijoter");
    });
  });

  // =====================================================================
  //  Production-shaped datasets: a household's full week
  // =====================================================================
  describe("full-week planning (production-shaped)", () => {
    /**
     * A realistic household week — seven dinners, each a genuine
     * multi-step recipe. Onion / garlic / carrot / parsley recur across
     * several recipes with a real prep step, so the optimizer has plenty
     * to pool; several recipes have a long passive cook (simmer / bake /
     * roast / braise) to exercise the background scheduler.
     */
    function householdWeek(): OptimizerRecipeInput[] {
      return [
        recipe(101, "Soupe à l'oignon", [
          simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 6, PIECE)]),
          simpleStep(2, 1, "Faire suer les oignons au beurre", "sweat", [
            line(ING.butter, 40, GRAM),
          ]),
          simpleStep(3, 2, "Mouiller au bouillon et laisser mijoter 30 min", "simmer"),
          simpleStep(4, 3, "Gratiner au four", "bake", [line(ING.cheese, 150, GRAM)]),
          simpleStep(5, 4, "Servir bien chaud", "plate"),
        ]),
        recipe(102, "Bœuf bourguignon", [
          simpleStep(10, 0, "Tailler les carottes", "chop", [line(ING.carrot, 400, GRAM)]),
          simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]),
          simpleStep(12, 2, "Colorer la viande", "brown", [line(ING.beef, 1200, GRAM)]),
          simpleStep(13, 3, "Déglacer au vin rouge", "deglaze"),
          simpleStep(14, 4, "Braiser 3 h à couvert", "braise"),
          simpleStep(15, 5, "Dresser", "plate"),
        ]),
        recipe(103, "Curry de lentilles", [
          simpleStep(20, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
          simpleStep(21, 1, "Presser l'ail", "mince", [line(ING.garlic, 3, PIECE)]),
          simpleStep(22, 2, "Faire revenir les épices", "panFry"),
          simpleStep(23, 3, "Ajouter lentilles et tomates, mijoter 25 min", "simmer", [
            line(ING.lentils, 300, GRAM),
            line(ING.tomato, 400, GRAM),
          ]),
          simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]),
        ]),
        recipe(104, "Poulet rôti & pommes de terre", [
          simpleStep(30, 0, "Préchauffer le four à 210°C", "preheat"),
          simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [
            line(ING.potato, 1, KILOGRAM),
          ]),
          simpleStep(32, 2, "Presser l'ail", "mince", [line(ING.garlic, 4, PIECE)]),
          simpleStep(33, 3, "Enfourner le poulet 1 h 15", "roast", [line(ING.chicken, 1600, GRAM)]),
          simpleStep(34, 4, "Laisser reposer 10 min", "rest"),
          simpleStep(35, 5, "Découper et dresser", "plate"),
        ]),
        recipe(105, "Gratin dauphinois", [
          simpleStep(40, 0, "Préchauffer le four à 180°C", "preheat"),
          simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [
            line(ING.potato, 1, KILOGRAM),
          ]),
          simpleStep(42, 2, "Émincer finement à la mandoline", "chop", [
            line(ING.potato, 1, KILOGRAM),
          ]),
          simpleStep(43, 3, "Monter le gratin avec la crème", "coat", [line(ING.cream, 500, GRAM)]),
          simpleStep(44, 4, "Cuire 1 h au four", "bake"),
        ]),
        recipe(106, "Risotto aux champignons", [
          simpleStep(50, 0, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]),
          simpleStep(51, 1, "Nacrer le riz", "panFry", [line(ING.rice, 320, GRAM)]),
          simpleStep(52, 2, "Mouiller louche à louche 18 min", "simmer"),
          simpleStep(53, 3, "Lier au beurre et parmesan", "mix", [line(ING.cheese, 80, GRAM)]),
          simpleStep(54, 4, "Servir aussitôt", "plate"),
        ]),
        recipe(107, "Salade & omelette", [
          simpleStep(60, 0, "Battre les œufs", "whisk", [line(ING.egg, 6, PIECE)]),
          simpleStep(61, 1, "Hacher le persil", "chop", [line(ING.parsley, 20, GRAM)]),
          simpleStep(62, 2, "Cuire l'omelette", "cook"),
          simpleStep(63, 3, "Assaisonner la salade", "season"),
          simpleStep(64, 4, "Servir", "plate"),
        ]),
      ];
    }

    it("produces a coherent plan: mise-en-place first, every phase non-empty, indices 0..n-1", () => {
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);

      expect(plan.recipes).to.have.lengthOf(week.length);
      expect(plan.phases.length).to.be.greaterThan(1);
      expect(plan.phases[0]?.kind).to.equal("mise-en-place");
      expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true);
      assertPhaseIndices(plan);
      assertUniqueIds(plan);
    });

    it("accounts for every one of the ~40 planned steps exactly once", () => {
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);
      assertEveryStepAccountedForOnce(week, plan);
    });

    it("pools the shared knife work — onion, garlic and potato each become one merged-prep task", () => {
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);
      const merged = mergedTasks(plan);
      const byIngredient = merged.map((t) => t.ingredients[0]?.ingredient.key).sort();

      // onion: recipes 101,102,103,106 — garlic: 103,104 — potato peel: 104,105
      expect(byIngredient).to.include("onion");
      expect(byIngredient).to.include("garlic");
      expect(byIngredient).to.include("potato");

      const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion");
      expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([101, 102, 103, 106]);
      // 6 + 3 + 2 + 1 pieces
      expect(onionPool?.ingredients[0]?.quantity).to.equal(12);
      expect(onionPool?.technique?.key).to.equal("chop");
    });

    it("pulls every 'préchauffer le four' into the mise-en-place phase", () => {
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);
      const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four");
      const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? [];
      expect(miseDescr.filter(startsWithPreheat)).to.have.lengthOf(2);
      const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description));
      expect(laterDescr.some(startsWithPreheat)).to.equal(false);
    });

    it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => {
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);
      const bgDescr = plan.phases.flatMap((p) => p.background.map((b) => b.description));
      expect(bgDescr.some((d) => d.includes("Braiser 3 h"))).to.equal(true);
      expect(bgDescr.some((d) => d.includes("Enfourner le poulet"))).to.equal(true);
      // Every background line is the echo of a real passive step scheduled earlier.
      const passiveStepIds = new Set(plan.phases.flatMap((p) => p.tasks).map((t) => t.id));
      for (const phase of plan.phases) {
        for (const bg of phase.background) {
          expect(passiveStepIds.has(bg.id.replace(/^bg:/, ""))).to.equal(true);
        }
      }
    });

    it("never emits a merged-prep for a cut that lives inside a cooking step (mandoline slice in the gratin)", () => {
      // Recipe 105 step 42 "Émincer finement à la mandoline" IS pure prep
      // (chop only) on potato — it should be eligible to pool with 105's own
      // peel? No: different technique. It pools with nothing else here
      // because no other recipe chops potato. So it stays a solo mise task.
      const week = householdWeek();
      const plan = optimizeCookingPlan(week);
      const mandoline = allTasks(plan).find(
        (t) => t.description === "Émincer finement à la mandoline",
      );
      expect(mandoline?.kind).to.equal("step");
      expect(plan.phases[0]?.tasks).to.include(mandoline);
    });

    it("is deterministic on the full week", () => {
      const a = optimizeCookingPlan(householdWeek());
      const b = optimizeCookingPlan(householdWeek());
      expect(JSON.stringify(a)).to.equal(JSON.stringify(b));
    });
  });

  // =====================================================================
  //  Two people / two households combined into one big cook
  // =====================================================================
  describe("multiple people — combined week", () => {
    /** Alice's 3 dinners + Bob's 3 dinners, all pooled into one session. */
    function combinedWeek(): OptimizerRecipeInput[] {
      const alice: OptimizerRecipeInput[] = [
        recipe(
          201,
          "Alice — Chili",
          [
            simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
            simpleStep(2, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]),
            simpleStep(3, 2, "Mijoter 40 min", "simmer"),
            simpleStep(4, 3, "Servir", "plate"),
          ],
          2,
          4,
        ),
        recipe(
          202,
          "Alice — Ratatouille",
          [
            simpleStep(10, 0, "Tailler les tomates", "concasse", [line(ING.tomato, 500, GRAM)]),
            simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]),
            simpleStep(12, 2, "Compoter à feu doux", "compote"),
            simpleStep(13, 3, "Dresser", "plate"),
          ],
          2,
          4,
        ),
        recipe(
          203,
          "Alice — Salade de lentilles",
          [
            simpleStep(20, 0, "Cuire les lentilles", "boil", [line(ING.lentils, 200, GRAM)]),
            simpleStep(21, 1, "Hacher le persil", "chop", [line(ING.parsley, 15, GRAM)]),
            simpleStep(22, 2, "Assaisonner", "season"),
          ],
          2,
          4,
        ),
      ];
      const bob: OptimizerRecipeInput[] = [
        recipe(
          301,
          "Bob — Bolognaise",
          [
            simpleStep(30, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
            simpleStep(31, 1, "Tailler les carottes", "chop", [line(ING.carrot, 200, GRAM)]),
            simpleStep(32, 2, "Colorer la viande", "brown", [line(ING.beef, 500, GRAM)]),
            simpleStep(33, 3, "Mijoter 1 h", "simmer"),
            simpleStep(34, 4, "Servir", "plate"),
          ],
          3,
          4,
        ),
        recipe(
          302,
          "Bob — Poulet basquaise",
          [
            simpleStep(40, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]),
            simpleStep(41, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]),
            simpleStep(42, 2, "Saisir le poulet", "brown", [line(ING.chicken, 800, GRAM)]),
            simpleStep(43, 3, "Mijoter 35 min", "simmer"),
            simpleStep(44, 4, "Dresser", "plate"),
          ],
          3,
          4,
        ),
        recipe(
          303,
          "Bob — Riz pilaf",
          [
            simpleStep(50, 0, "Nacrer le riz", "panFry", [line(ING.rice, 250, GRAM)]),
            simpleStep(51, 1, "Cuire couvert 17 min", "simmer"),
            simpleStep(52, 2, "Égrainer et servir", "plate"),
          ],
          3,
          4,
        ),
      ];
      return [...alice, ...bob];
    }

    it("pools knife work across both people's recipes", () => {
      const week = combinedWeek();
      const plan = optimizeCookingPlan(week);
      const merged = mergedTasks(plan);

      const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion");
      // onion chopped in 201, 202, 301, 302
      expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 202, 301, 302]);
      // 2×(2/4) + 1×(2/4) + 2×(3/4) + 2×(3/4) = 1 + 0.5 + 1.5 + 1.5 = 4.5
      expect(onionPool?.ingredients[0]?.quantity).to.equal(4.5);

      const garlicPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "garlic");
      expect(garlicPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 302]);
    });

    it("accounts for every step and keeps every id unique across the combined plan", () => {
      const week = combinedWeek();
      const plan = optimizeCookingPlan(week);
      assertEveryStepAccountedForOnce(week, plan);
      assertUniqueIds(plan);
      assertPhaseIndices(plan);
    });

    it("still schedules six simultaneous simmers without an empty or infinite phase", () => {
      const week = combinedWeek();
      const plan = optimizeCookingPlan(week);
      expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true);

      // Every recipe's very last step is scheduled somewhere.
      const stepTaskIds = new Set(stepTasks(plan).map((t) => t.id));
      week.forEach((r, recipeIndex) => {
        const lastStep = [...r.steps].sort((a, b) => a.order - b.order).at(-1);
        expect(
          stepTaskIds.has(`step:${recipeIndex}:${lastStep?.stepId}`),
          `${r.name}'s last step should be scheduled`,
        ).to.equal(true);
      });
    });

    it("orders the plan so all pooled prep is done before any recipe's cooking step", () => {
      const week = combinedWeek();
      const plan = optimizeCookingPlan(week);
      const firstCookingPhaseIndex = plan.phases.findIndex((p) => p.kind !== "mise-en-place");
      const misePhases = plan.phases.slice(0, firstCookingPhaseIndex);
      // All merged-prep tasks live in the mise-en-place phase(s).
      const mergedOutsideMise = plan.phases
        .slice(firstCookingPhaseIndex)
        .flatMap((p) => p.tasks)
        .filter((t) => t.kind === "merged-prep");
      expect(mergedOutsideMise).to.deep.equal([]);
      expect(misePhases.length).to.equal(1);
    });
  });
});
