import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { and, eq, inArray, count, sql } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
import { cancelTimedOutCookingSessions } from "@app/database";
import { computeBalance } from "@app/inventory-engine";
import type { Unit } from "@app/shared-types";

describe("cooking sessions", () => {
  const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
  const config = loadConfig();
  let app: Awaited<ReturnType<typeof buildServer>>;
  let token: string;
  let userId: string;
  let householdId: string;
  let recipeId: string;
  const email = "cooking-session-test@example.invalid";

  async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) {
    const [location] = await testDb.db
      .select({ id: schema.storageLocations.id })
      .from(schema.storageLocations)
      .where(eq(schema.storageLocations.householdId, householdId))
      .limit(1);
    const [item] = await testDb.db
      .insert(schema.inventoryItems)
      .values({
        householdId,
        canonicalIngredientId,
        displayName,
        quantity,
        unit,
        storageLocationId: location!.id,
        source: "manual_search",
      })
      .returning();
    await testDb.db.insert(schema.inventoryTransactions).values({
      householdId,
      inventoryItemId: item!.id,
      type: "purchase",
      quantityDelta: quantity,
      unit,
      refType: "test_setup",
      actorUserId: userId,
    });
    return item!.id;
  }

  async function cleanup() {
    const existing = await testDb.db
      .select({ id: schema.users.id })
      .from(schema.users)
      .where(inArray(schema.users.email, [email]));
    for (const u of existing) {
      const sessions = await testDb.db
        .select({ id: schema.cookingSessions.id })
        .from(schema.cookingSessions)
        .where(eq(schema.cookingSessions.startedByUserId, u.id));
      for (const s of sessions) {
        await testDb.db
          .delete(schema.inventoryTransactions)
          .where(eq(schema.inventoryTransactions.cookingSessionId, s.id));
        await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id));
        await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id));
        await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id));
      }
      await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id));
      const memberships = await testDb.db
        .select({ householdId: schema.householdMembers.householdId })
        .from(schema.householdMembers)
        .where(eq(schema.householdMembers.userId, u.id));
      for (const m of memberships) {
        await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
        await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, m.householdId));
        await testDb.db.delete(schema.meals).where(eq(schema.meals.householdId, m.householdId));
        await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.householdId, m.householdId));
        await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.householdId, m.householdId));
        await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
        await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
        await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
        await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
      }
      await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
      await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id));
      await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
    }
  }

  beforeAll(async () => {
    await cleanup();
    app = await buildServer(config);
    await app.ready();

    const res = await app.inject({
      method: "POST",
      url: "/v1/auth/register",
      payload: { email, password: "Password123!", displayName: "Cooking Test" },
    });
    token = (JSON.parse(res.body) as { accessToken: string }).accessToken;
    const profile = await app.inject({ method: "GET", url: "/v1/me", headers: { authorization: `Bearer ${token}` } });
    userId = (JSON.parse(profile.body) as { id: string }).id;

    const quick = await app.inject({
      method: "POST",
      url: "/v1/onboarding/quick-start",
      headers: { authorization: `Bearer ${token}` },
      payload: { goals: ["less_waste"], precisionMode: "simple" },
    });
    householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;

    // Hitta ett seedat recept
    const recipes = await app.inject({
      method: "GET",
      url: "/v1/recipes?limit=1",
      headers: { authorization: `Bearer ${token}` },
    });
    recipeId = (JSON.parse(recipes.body) as { recipes: Array<{ id: string }> }).recipes[0]!.id;
  });

  afterAll(async () => {
    await cleanup();
    await closeDatabase();
    await app.close();
  });

  it("creates a planned session without firing cooking_session_started", async () => {
    const res = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portions: 4, mealType: "dinner" },
    });
    expect(res.statusCode).toBe(201);
    const body = JSON.parse(res.body) as { session: { status: string; plannedPortions: number } };
    expect(body.session.status).toBe("planned");
    expect(body.session.plannedPortions).toBe(4);

    const events = await testDb.db
      .select({ eventName: schema.productAnalyticsEvents.eventName })
      .from(schema.productAnalyticsEvents)
      .where(eq(schema.productAnalyticsEvents.userId, userId));
    expect(events.filter((e) => e.eventName === "cooking_session_started").length).toBe(0);
  });

  it("starts a planned session and fires cooking_session_started", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portions: 2 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const res = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body) as { session: { status: string } };
    expect(body.session.status).toBe("started");

    const started = await testDb.db
      .select()
      .from(schema.productAnalyticsEvents)
      .where(eq(schema.productAnalyticsEvents.userId, userId))
      .orderBy(schema.productAnalyticsEvents.occurredAt);
    expect(started.filter((e) => e.eventName === "cooking_session_started").length).toBeGreaterThanOrEqual(1);
  });

  it("cancels a session without touching inventory", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const before = await testDb.db
      .select({ count: count(schema.inventoryTransactions.id) })
      .from(schema.inventoryTransactions)
      .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
      .where(eq(schema.inventoryItems.householdId, householdId));

    const res = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/cancel`,
      headers: { authorization: `Bearer ${token}` },
      payload: { reason: "vi åt ute" },
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body) as { session: { status: string } };
    expect(body.session.status).toBe("cancelled");

    const after = await testDb.db
      .select({ count: count(schema.inventoryTransactions.id) })
      .from(schema.inventoryTransactions)
      .innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
      .where(eq(schema.inventoryItems.householdId, householdId));
    expect(after[0]!.count).toBe(before[0]!.count);
  });

  it("completes a started session and links inventory, meals and recipe_cooks", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const res = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, deductInventory: true },
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body) as {
      session: { status: string };
      mealIds: string[];
      inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
    };
    expect(body.session.status).toBe("completed");
    expect(body.mealIds.length).toBeGreaterThan(0);

    const txCount = await testDb.db
      .select({ count: count(schema.inventoryTransactions.id) })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
    expect(Number(txCount[0]!.count)).toBeGreaterThanOrEqual(body.inventoryDeductions.length);

    const cookCount = await testDb.db
      .select({ count: count(schema.recipeCooks.id) })
      .from(schema.recipeCooks)
      .where(eq(schema.recipeCooks.cookingSessionId, sessionId));
    expect(Number(cookCount[0]!.count)).toBe(1);

    // Transaktionsinvariant: computeBalance ska matcha item.quantity
    for (const d of body.inventoryDeductions) {
      const item = await testDb.db
        .select({ id: schema.inventoryItems.id, quantity: schema.inventoryItems.quantity })
        .from(schema.inventoryItems)
        .where(eq(schema.inventoryItems.id, d.itemId))
        .limit(1);
      const txs = await testDb.db
        .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
        .from(schema.inventoryTransactions)
        .where(eq(schema.inventoryTransactions.inventoryItemId, d.itemId));
      const balance = computeBalance(txs);
      expect(Math.abs(balance.balance - (item[0]?.quantity ?? 0))).toBeLessThan(1e-6);
    }
  });

  it("legacy POST /v1/recipes/:id/cook still works as shortcut", async () => {
    const res = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true },
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null };
    expect(body.ok).toBe(true);
    expect(body.sessionId).toBeDefined();
    expect(body.mealBoxId).toBeDefined();
  });

  it("legacy /cook stores actual portions and leftover estimate on the session row", async () => {
    const res = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: {
        portionsCooked: 4,
        mealBoxPortions: 1,
        actualPortionsEaten: 2,
        leftoverEstimatePortions: 1,
        leftoverNote: "sparas i kylen",
        deductInventory: true,
      },
    });
    expect(res.statusCode).toBe(200);
    const { sessionId } = JSON.parse(res.body) as { sessionId: string };

    const session = await testDb.db
      .select()
      .from(schema.cookingSessions)
      .where(eq(schema.cookingSessions.id, sessionId))
      .limit(1);
    expect(session[0]!.actualPortionsEaten).toBe(2);
    expect(session[0]!.leftoverEstimatePortions).toBe(1);
    expect(session[0]!.leftoverNote).toBe("sparas i kylen");
  });

  it("legacy /cook updates cooking assumption profiles", async () => {
    await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));

    const recipe = (await app.inject({
      method: "GET",
      url: `/v1/recipes/${recipeId}`,
      headers: { authorization: `Bearer ${token}` },
    })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
    const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
    expect(firstNonOptionalIngredientId).toBeDefined();

    await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: {
        portionsCooked: 4,
        actualPortionsEaten: 3,
        leftoverEstimatePortions: 1,
        deductInventory: true,
      },
    });

    const profile = await testDb.db
      .select()
      .from(schema.cookingAssumptionProfiles)
      .where(
        and(
          eq(schema.cookingAssumptionProfiles.householdId, householdId),
          eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
        ),
      )
      .limit(1);
    expect(profile.length).toBe(1);
    expect(profile[0]!.observationCount).toBe(1);
    expect(profile[0]!.averageEatenPortions).toBe(3);
    expect(profile[0]!.averageLeftoverPortions).toBe(1);
  });

  it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => {
    const before = await testDb.db
      .select({ id: schema.productAnalyticsEvents.id, eventName: schema.productAnalyticsEvents.eventName })
      .from(schema.productAnalyticsEvents)
      .where(eq(schema.productAnalyticsEvents.userId, userId));
    const beforeIds = new Set(before.map((e) => e.id));

    const res = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, deductInventory: true },
    });
    expect(res.statusCode).toBe(200);
    const { sessionId } = JSON.parse(res.body) as { sessionId: string };

    const after = await testDb.db
      .select()
      .from(schema.productAnalyticsEvents)
      .where(eq(schema.productAnalyticsEvents.userId, userId));
    const newEvents = after.filter((e) => !beforeIds.has(e.id));
    const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
    const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId);
    const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId);
    expect(started.length).toBe(1);
    expect(completed.length).toBe(1);
  });

  it("legacy /cook rejects when eaten + leftovers exceed planned portions", async () => {
    const res = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: {
        portionsCooked: 4,
        mealBoxPortions: 0,
        actualPortionsEaten: 3,
        leftoverEstimatePortions: 2,
      },
    });
    expect(res.statusCode).toBe(400);
  });

  it("stores actual portions and leftover estimate on complete", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const res = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } };
    expect(body.session.actualPortionsEaten).toBe(2);
    expect(body.session.leftoverEstimatePortions).toBe(1);
  });

  it("rejects complete when eaten + leftovers exceed planned portions", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const res = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2 },
    });
    expect(res.statusCode).toBe(400);
  });

  it("updates cooking assumption profiles per household and ingredient", async () => {
    await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const recipe = (await app.inject({
      method: "GET",
      url: `/v1/recipes/${recipeId}`,
      headers: { authorization: `Bearer ${token}` },
    })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
    const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
    expect(firstNonOptionalIngredientId).toBeDefined();

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
    });

    const profile = await testDb.db
      .select()
      .from(schema.cookingAssumptionProfiles)
      .where(
        and(
          eq(schema.cookingAssumptionProfiles.householdId, householdId),
          eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
        ),
      )
      .limit(1);
    expect(profile.length).toBe(1);
    expect(profile[0]!.observationCount).toBe(1);
    expect(profile[0]!.averageEatenPortions).toBe(2);
    expect(profile[0]!.averageLeftoverPortions).toBe(1);
  });

  it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => {
    await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));

    // Skapa ett recept där den första ingrediensen är valfri.
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `optional-first-${Date.now()}`,
        titleSv: "Testrecept valfri först",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: {
          cuisine: "international",
          vegetables: [],
          flavorProfile: [],
          spiceLevel: 0,
          method: "stovetop",
          timeMinutes: 15,
          calories: 100,
          proteinGrams: 5,
        },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;

    await testDb.db.insert(schema.recipeIngredients).values([
      {
        recipeId: testRecipeId,
        canonicalIngredientId: "olive_oil",
        displayNameSv: "Olivolja",
        quantity: 1,
        unit: "TABLESPOON",
        optional: true,
        sortOrder: 0,
      },
      {
        recipeId: testRecipeId,
        canonicalIngredientId: "pasta_dry",
        displayNameSv: "Pasta",
        quantity: 320,
        unit: "GRAM",
        optional: false,
        sortOrder: 1,
      },
    ]);

    // Completa en session och skriv profil för den icke-valfria ingrediensen.
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 1 },
    });

    const assumptions = await app.inject({
      method: "GET",
      url: `/v1/recipes/${testRecipeId}/cooking-assumptions`,
      headers: { authorization: `Bearer ${token}` },
    });
    expect(assumptions.statusCode).toBe(200);
    const body = JSON.parse(assumptions.body) as {
      defaultActualPortionsEaten: number | null;
      defaultLeftoverEstimatePortions: number | null;
      observationCount: number;
    };
    expect(body.observationCount).toBe(1);
    expect(body.defaultActualPortionsEaten).toBe(3);
    expect(body.defaultLeftoverEstimatePortions).toBe(1);

    // Städa upp testreceptet.
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
    // complete
    const startComplete = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const completeId = (JSON.parse(startComplete.body) as { session: { id: string } }).session.id;
    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${completeId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, deductInventory: true },
    });

    // cancel
    const startCancel = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const cancelId = (JSON.parse(startCancel.body) as { session: { id: string } }).session.id;
    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${cancelId}/cancel`,
      headers: { authorization: `Bearer ${token}` },
      payload: { reason: "test" },
    });

    const names = (
      await testDb.db
        .select({ eventName: schema.productAnalyticsEvents.eventName })
        .from(schema.productAnalyticsEvents)
        .where(eq(schema.productAnalyticsEvents.userId, userId))
    ).map((r) => r.eventName);
    expect(names).toContain("cooking_session_completed");
    expect(names).toContain("cooking_session_cancelled");
  });

  it("times out started sessions older than 24 h", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    // Simulera 25 h gammal session
    await testDb.db
      .update(schema.cookingSessions)
      .set({ startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
      .where(eq(schema.cookingSessions.id, sessionId));

    const timedOut = await cancelTimedOutCookingSessions(testDb.db);
    expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
  });

  it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => {
    const recipe = (await app.inject({
      method: "GET",
      url: `/v1/recipes/${recipeId}`,
      headers: { authorization: `Bearer ${token}` },
    })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
    const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;

    // Sätt upp ett känt lager om receptet har en icke-valfri ingrediens.
    let itemId: string | undefined;
    if (firstNonOptional) {
      itemId = await createItemWithPurchase(firstNonOptional, "Testvara", 1000, "GRAM");
    }

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const complete = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 1, actualPortionsEaten: 3, leftoverEstimatePortions: 1 },
    });
    expect(complete.statusCode).toBe(200);

    const txsBefore = await testDb.db
      .select({ count: count(schema.inventoryTransactions.id) })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(200);
    const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] };
    expect(undoBody.ok).toBe(true);

    // Sessionstatus = undone.
    const session = await testDb.db
      .select()
      .from(schema.cookingSessions)
      .where(eq(schema.cookingSessions.id, sessionId))
      .limit(1);
    expect(session[0]!.status).toBe("undone");

    // Append-only: fler transaktioner efter undo.
    const txsAfter = await testDb.db
      .select({ count: count(schema.inventoryTransactions.id) })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
    expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count));

    // Meals borttagna.
    const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId));
    expect(Number(meals[0]!.count)).toBe(0);

    // Matlådor markerade discarded.
    const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
    expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length);
    for (const box of boxes) expect(box.status).toBe("discarded");

    // recipe_cooks borttagen och cookCount backad.
    const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId));
    expect(cooks.length).toBe(0);

    // Inventory-transaktionsinvariant.
    if (itemId) {
      const item = await testDb.db
        .select({ quantity: schema.inventoryItems.quantity })
        .from(schema.inventoryItems)
        .where(eq(schema.inventoryItems.id, itemId))
        .limit(1);
      const itemTxs = await testDb.db
        .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
        .from(schema.inventoryTransactions)
        .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
      const balance = computeBalance(itemTxs);
      expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
    }

    // Analytics.
    const analytics = await testDb.db
      .select()
      .from(schema.productAnalyticsEvents)
      .where(eq(schema.productAnalyticsEvents.userId, userId))
      .orderBy(schema.productAnalyticsEvents.occurredAt);
    const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
    expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true);

    if (itemId) {
      await testDb.db
        .delete(schema.inventoryItems)
        .where(eq(schema.inventoryItems.id, itemId));
    }
  });

  it("undo is rejected after 24h window", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0 },
    });

    // Simulera 25 h gammal completed session.
    await testDb.db
      .update(schema.cookingSessions)
      .set({ completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
      .where(eq(schema.cookingSessions.id, sessionId));

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(409);
  });

  it("partial consumption deducts only actual portions eaten from inventory", async () => {
    // Skapa ett enkelt recept med en enda icke-valfri ingrediens.
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `partial-${Date.now()}`,
        titleSv: "Partial test",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;
    await testDb.db.insert(schema.recipeIngredients).values({
      recipeId: testRecipeId,
      canonicalIngredientId: "pasta_dry",
      displayNameSv: "Pasta",
      quantity: 400,
      unit: "GRAM",
      optional: false,
      sortOrder: 0,
    });

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
    const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM");

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
    });

    // 4 portioner → 400 g, 2 ätna + 0 rester = 2 tillagade → 200 g borta, 200 g kvar.
    const afterComplete = await testDb.db
      .select({ quantity: schema.inventoryItems.quantity })
      .from(schema.inventoryItems)
      .where(eq(schema.inventoryItems.id, itemId))
      .limit(1);
    expect(afterComplete[0]!.quantity).toBeCloseTo(200, 1);

    // Undo återställer.
    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(200);
    const afterUndo = await testDb.db
      .select({ quantity: schema.inventoryItems.quantity })
      .from(schema.inventoryItems)
      .where(eq(schema.inventoryItems.id, itemId))
      .limit(1);
    expect(afterUndo[0]!.quantity).toBeCloseTo(400, 1);

    // Städa testreceptet och lagerposten.
    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("leftover estimate must be at least meal box portions", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const complete = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 2, actualPortionsEaten: 1, leftoverEstimatePortions: 1 },
    });
    expect(complete.statusCode).toBe(400);
    expect(complete.body).toContain("BAD_REQUEST");
  });

  it("meal boxes are part of cooked food: 2 eaten + 2 boxed = 100% inventory deduction", async () => {
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `boxreg-${Date.now()}`,
        titleSv: "Box regression test",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;
    await testDb.db.insert(schema.recipeIngredients).values({
      recipeId: testRecipeId,
      canonicalIngredientId: "chicken_breast",
      displayNameSv: "Kyckling",
      quantity: 400,
      unit: "GRAM",
      optional: false,
      sortOrder: 0,
    });

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
    const itemId = await createItemWithPurchase("chicken_breast", "Kyckling", 400, "GRAM");

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const complete = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 2, actualPortionsEaten: 2, leftoverEstimatePortions: 2 },
    });
    expect(complete.statusCode).toBe(200);

    // Alla 400 g ska vara borta (ätet + matlådor = tillagat).
    const item = await testDb.db
      .select({ quantity: schema.inventoryItems.quantity })
      .from(schema.inventoryItems)
      .where(eq(schema.inventoryItems.id, itemId))
      .limit(1);
    expect(item[0]!.quantity).toBeCloseTo(0, 1);

    // Matlådan ska ha 2 portioner.
    const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
    expect(boxes.length).toBe(1);
    expect(boxes[0]!.portions).toBe(2);

    // Invariant.
    const txs = await testDb.db
      .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
    const balance = computeBalance(txs);
    expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "chicken_breast")));
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("2 eaten + 1 leftover = 75% deduction; 1 portion never cooked stays raw", async () => {
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `leftover-${Date.now()}`,
        titleSv: "Leftover test",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;
    await testDb.db.insert(schema.recipeIngredients).values({
      recipeId: testRecipeId,
      canonicalIngredientId: "carrot",
      displayNameSv: "Morötter",
      quantity: 400,
      unit: "GRAM",
      optional: false,
      sortOrder: 0,
    });

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
    const itemId = await createItemWithPurchase("carrot", "Morötter", 400, "GRAM");

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const complete = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
    });
    expect(complete.statusCode).toBe(200);

    // 3 av 4 tillagade → 300 g borta, 100 g kvar som aldrig tillagats.
    const item = await testDb.db
      .select({ quantity: schema.inventoryItems.quantity })
      .from(schema.inventoryItems)
      .where(eq(schema.inventoryItems.id, itemId))
      .limit(1);
    expect(item[0]!.quantity).toBeCloseTo(100, 1);

    const txs = await testDb.db
      .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
    const balance = computeBalance(txs);
    expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "carrot")));
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("defaults leftover estimate to meal box portions: 3 eaten + 1 box = 100% deduction", async () => {
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `default-leftover-${Date.now()}`,
        titleSv: "Default leftover test",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;
    await testDb.db.insert(schema.recipeIngredients).values({
      recipeId: testRecipeId,
      canonicalIngredientId: "potato",
      displayNameSv: "Potatis",
      quantity: 400,
      unit: "GRAM",
      optional: false,
      sortOrder: 0,
    });

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato")));
    const itemId = await createItemWithPurchase("potato", "Potatis", 400, "GRAM");

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const complete = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      // leftoverEstimatePortions UTELÄMNAD – wrapper ska defaulta till mealBoxPortions=1.
      payload: { mealBoxPortions: 1, actualPortionsEaten: 3 },
    });
    expect(complete.statusCode).toBe(200);

    // 3 ätna + 1 matlåda = 4 tillagade → allt 400 g borta.
    const item = await testDb.db
      .select({ quantity: schema.inventoryItems.quantity })
      .from(schema.inventoryItems)
      .where(eq(schema.inventoryItems.id, itemId))
      .limit(1);
    expect(item[0]!.quantity).toBeCloseTo(0, 1);

    // Lådan ska ha 1 portion.
    const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
    expect(boxes.length).toBe(1);
    expect(boxes[0]!.portions).toBe(1);

    // Invariant.
    const txs = await testDb.db
      .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
      .from(schema.inventoryTransactions)
      .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
    const balance = computeBalance(txs);
    expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "potato")));
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("undo + new complete maintains inventory invariant", async () => {
    const [recipe] = await testDb.db
      .insert(schema.recipes)
      .values({
        slug: `invariant-${Date.now()}`,
        titleSv: "Invariant test",
        descriptionSv: "",
        cuisine: "international",
        mealTypes: ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        prepTimeMinutes: 5,
        cookTimeMinutes: 10,
        totalTimeMinutes: 15,
        portions: 4,
        nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
        allergens: [],
        spiceLevel: 0,
        dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
        status: "published",
        verificationStatus: "unverified",
        sourceType: "own_editorial",
        creatorDisplayName: "Test",
      })
      .returning();
    const testRecipeId = recipe!.id;
    await testDb.db.insert(schema.recipeIngredients).values({
      recipeId: testRecipeId,
      canonicalIngredientId: "rice_white",
      displayNameSv: "Ris",
      quantity: 400,
      unit: "GRAM",
      optional: false,
      sortOrder: 0,
    });

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
    const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM");

    async function assertInvariant() {
      const item = await testDb.db
        .select({ quantity: schema.inventoryItems.quantity })
        .from(schema.inventoryItems)
        .where(eq(schema.inventoryItems.id, itemId))
        .limit(1);
      const txs = await testDb.db
        .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
        .from(schema.inventoryTransactions)
        .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
      const balance = computeBalance(txs);
      expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
    }

    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 4, leftoverEstimatePortions: 0 },
    });
    await assertInvariant();

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    await assertInvariant();

    // Ny session + complete efter undo ska bibehålla invarianten.
    const start2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${testRecipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true, portions: 4 },
    });
    const sessionId2 = (JSON.parse(start2.body) as { session: { id: string } }).session.id;
    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId2}/complete`,
      headers: { authorization: `Bearer ${token}` },
      payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
    });
    await assertInvariant();

    await testDb.db
      .delete(schema.inventoryItems)
      .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
    await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
    await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
  });

  it("legacy /cook followed by undo works", async () => {
    const cook = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
    });
    expect(cook.statusCode).toBe(200);
    const { sessionId } = JSON.parse(cook.body) as { sessionId: string };

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(200);

    const session = await testDb.db
      .select()
      .from(schema.cookingSessions)
      .where(eq(schema.cookingSessions.id, sessionId))
      .limit(1);
    expect(session[0]!.status).toBe("undone");

    const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
    expect(boxes.every((b) => b.status === "discarded")).toBe(true);
  });

  it("undo rolls back cooking assumption profiles", async () => {
    await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));

    const recipe = (await app.inject({
      method: "GET",
      url: `/v1/recipes/${recipeId}`,
      headers: { authorization: `Bearer ${token}` },
    })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
    const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
    expect(firstNonOptionalIngredientId).toBeDefined();

    const cook = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true },
    });
    const { sessionId } = JSON.parse(cook.body) as { sessionId: string };

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });

    const profile = await testDb.db
      .select()
      .from(schema.cookingAssumptionProfiles)
      .where(
        and(
          eq(schema.cookingAssumptionProfiles.householdId, householdId),
          eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
        ),
      )
      .limit(1);
    expect(profile.length).toBe(0);
  });

  it("cannot undo a non-completed session", async () => {
    const start = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook/start`,
      headers: { authorization: `Bearer ${token}` },
      payload: { startNow: true },
    });
    const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(409);
  });

  it("3d: leftovers merge into existing meal box with same recipe, date and frozen state", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));

    const cook1 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
    });
    expect(cook1.statusCode).toBe(200);
    const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };

    const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1));
    expect(boxes1.length).toBe(1);
    const boxId = boxes1[0]!.id;

    const cook2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
    });
    expect(cook2.statusCode).toBe(200);
    const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };

    const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
    expect(boxes2[0]!.portions).toBe(3);
    expect(boxes2[0]!.portionsRemaining).toBe(3);

    const sessions = await testDb.db
      .select({ mutations: schema.cookingSessions.mealBoxMutations })
      .from(schema.cookingSessions)
      .where(eq(schema.cookingSessions.id, sessionId2));
    expect(sessions[0]!.mutations).toEqual([{ mealBoxId: boxId, deltaPortions: 2, frozen: false }]);
  });

  it("3d: leftovers do not merge across cooking dates", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
    const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);

    const cook1 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true },
    });
    expect(cook1.statusCode).toBe(200);
    const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };

    const cook2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
    });
    expect(cook2.statusCode).toBe(200);
    const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };

    const allBoxes = await testDb.db
      .select()
      .from(schema.mealBoxes)
      .where(
        and(
          eq(schema.mealBoxes.householdId, householdId),
          inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
        ),
      );
    expect(allBoxes.length).toBe(2);
  });

  it("3d: fridge and freezer leftovers do not merge", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));

    const cook1 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
    });
    expect(cook1.statusCode).toBe(200);
    const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };

    const cook2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true },
    });
    expect(cook2.statusCode).toBe(200);
    const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };

    const boxes = await testDb.db
      .select()
      .from(schema.mealBoxes)
      .where(
        and(
          eq(schema.mealBoxes.householdId, householdId),
          inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
        ),
      );
    expect(boxes.length).toBe(2);
    expect(boxes.filter((b) => b.frozen).length).toBe(1);
    expect(boxes.filter((b) => !b.frozen).length).toBe(1);
  });

  it("3d: undo restores merged meal box portions", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));

    const cook1 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
    });
    const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
    const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id;

    const cook2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
    });
    const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };

    const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
    expect(beforeUndo[0]!.portions).toBe(3);

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId2}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(200);

    const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
    expect(afterUndo[0]!.portions).toBe(1);
    expect(afterUndo[0]!.portionsRemaining).toBe(1);
    expect(afterUndo[0]!.status).toBe("available");
  });

  it("3d: undo discards a meal box created solely by the session", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));

    const cook = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
    });
    const { sessionId } = JSON.parse(cook.body) as { sessionId: string };

    const undo = await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });
    expect(undo.statusCode).toBe(200);

    const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
    expect(boxes[0]!.status).toBe("discarded");
    expect(boxes[0]!.portionsRemaining).toBe(0);
  });

  it("3d: ledger invariant holds after merge and undo", async () => {
    await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
    const itemId = await createItemWithPurchase("carrot", "morot", 10, "COUNT");

    const cook1 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
    });
    const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };

    const cook2 = await app.inject({
      method: "POST",
      url: `/v1/recipes/${recipeId}/cook`,
      headers: { authorization: `Bearer ${token}` },
      payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
    });
    const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };

    const assertItemBalance = async () => {
      const txs = await testDb.db
        .select({
          type: schema.inventoryTransactions.type,
          quantityDelta: schema.inventoryTransactions.quantityDelta,
          unit: schema.inventoryTransactions.unit,
        })
        .from(schema.inventoryTransactions)
        .where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
      const balance = computeBalance(txs);
      const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1);
      expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6);
    };

    await assertItemBalance();

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId2}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });

    await assertItemBalance();

    await app.inject({
      method: "POST",
      url: `/v1/cooking-sessions/${sessionId1}/undo`,
      headers: { authorization: `Bearer ${token}` },
      payload: {},
    });

    await assertItemBalance();
  });
});
