import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import { whatToEatQuerySchema } from "@app/validation";
import {
  computeCoverage,
  checkRecipeSafety,
  isRecipeSafe,
  type IngredientSafetyInfo,
  type PantryItem,
} from "@app/recipe-engine";
import {
  depersonalize,
  isEventActive,
  parseCraving,
  rankAll,
  seasonForDate,
  summarizeContext,
  viewWeights,
  type CookingAssumption,
  type RecommendationCandidate,
  type RecommendationContext,
} from "@app/recommendation-engine";
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
import { parse } from "../lib/errors.js";
import { getActiveHouseholdId, todayIso } from "../lib/helpers.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import { resolveIngredientNames, resolveRecipeTitles } from "../lib/contentLanguage.js";

/**
 * "Vad ska vi äta?" (spec §18) – appens viktigaste endpoint.
 *
 * Pipeline:
 *  1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext.
 *  2. Deterministisk säkerhetsfiltrering (spec §61.2) – blockers försvinner.
 *  3. Täckningsberäkning mot lagret + poängsättning med förklaringar.
 *  4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24).
 *  5. (Bakom flagga) AAMOS får omranka topplistan – aldrig lägga till recept.
 */
/** Kock-alias (spec: named-chef search) inkl. medvetna felstavningar, så att
 * sök hittar rätt kock även vid felskrivning ("remsey" → Gordon Ramsay). */
const CHEF_ALIASES: Record<string, string> = {
  gordon: "Gordon Ramsay",
  ramsay: "Gordon Ramsay",
  ramsey: "Gordon Ramsay",
  remsey: "Gordon Ramsay",
  remsay: "Gordon Ramsay",
  jamie: "Jamie Oliver",
  oliver: "Jamie Oliver",
  nigella: "Nigella Lawson",
  lawson: "Nigella Lawson",
  yotam: "Yotam Ottolenghi",
  ottolenghi: "Yotam Ottolenghi",
  marcus: "Marcus Samuelsson",
  samuelsson: "Marcus Samuelsson",
  giada: "Giada De Laurentiis",
  laurentiis: "Giada De Laurentiis",
  emeril: "Emeril Lagasse",
  lagasse: "Emeril Lagasse",
  ina: "Ina Garten",
  garten: "Ina Garten",
  "barefoot contessa": "Ina Garten",
  raymond: "Raymond Blanc",
  blanc: "Raymond Blanc",
  rick: "Rick Stein",
  stein: "Rick Stein",
};

function resolveChefAliases(search: string): string[] {
  const s = search.toLowerCase().trim();
  if (s.length < 3) return [];
  const hits = new Set<string>();
  for (const [alias, name] of Object.entries(CHEF_ALIASES)) {
    if (s === alias || s.includes(alias) || alias.includes(s)) hits.add(name);
  }
  return [...hits];
}

/** "Menade du …?" – närmaste term via trigram word_similarity mot titlar,
 * kocknamn, ingredienser och högtider. Null om inget är nära nog. */
async function computeSearchSuggestion(
  app: FastifyInstance,
  search: string,
): Promise<string | null> {
  const result = await app.db.execute(sql`
    SELECT term FROM (
      SELECT title_sv AS term FROM recipes WHERE status = 'published'
      UNION SELECT DISTINCT creator_display_name FROM recipes WHERE creator_display_name NOT ILIKE '%redaktion%'
      UNION SELECT name_sv FROM canonical_ingredients
      UNION SELECT name_sv FROM season_events
    ) c
    WHERE word_similarity(${search}, term) > 0.4
    ORDER BY word_similarity(${search}, term) DESC
    LIMIT 1
  `);
  const rows = (Array.isArray(result) ? result : result.rows) as Array<{ term: string }>;
  return rows[0]?.term ?? null;
}

export async function recommendationRoutes(app: FastifyInstance) {
  const auth = { preHandler: [app.authenticate] };

  app.get("/v1/recommendations/what-to-eat", auth, async (req) => {
    const q = parse(whatToEatQuerySchema, req.query);
    // Graceful for users without a household yet (e.g. after step A onboarding):
    // treat it as an empty pantry / single-person context instead of failing.
    const householdId = await getActiveHouseholdId(app.db, req.userId);
    const today = new Date();

    // --- 1. Kontext: lager ---
    const stockRows = householdId
      ? await app.db
          .select({
            item: schema.inventoryItems,
            locationType: schema.storageLocations.type,
            shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
            density: schema.canonicalIngredients.densityGPerMl,
            gramsPerPiece: schema.canonicalIngredients.gramsPerPiece,
          })
          .from(schema.inventoryItems)
          .innerJoin(
            schema.storageLocations,
            eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
          )
          .leftJoin(
            schema.canonicalIngredients,
            eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
          )
          .where(
            and(
              eq(schema.inventoryItems.householdId, householdId),
              isNull(schema.inventoryItems.depletedAt),
              gt(schema.inventoryItems.quantity, 0),
            ),
          )
      : [];

    const pantry: PantryItem[] = stockRows.map((r) => ({
      id: r.item.id,
      canonicalIngredientId: r.item.canonicalIngredientId,
      quantity: r.item.quantity,
      unit: r.item.unit,
      bestBeforeDate: r.item.bestBeforeDate,
      useByDate: r.item.useByDate,
      openedAt: r.item.openedAt,
      frozenAt: r.item.frozenAt,
      thawedAt: r.item.thawedAt,
      purchasedAt: r.item.purchasedAt,
      storageLocationType: r.locationType,
      shelfLifeGuidance: r.shelfLife,
    }));
    const unitInfo = new Map(
      stockRows
        .filter((r) => r.item.canonicalIngredientId)
        .map((r) => [
          r.item.canonicalIngredientId!,
          { densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece },
        ]),
    );

    // --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) ---
    const members = householdId
      ? await app.db
          .select({ userId: schema.householdMembers.userId })
          .from(schema.householdMembers)
          .where(eq(schema.householdMembers.householdId, householdId))
      : [];
    const memberIds = members.length ? members.map((m) => m.userId) : [req.userId];
    const allPrefs = await app.db
      .select()
      .from(schema.userPreferences)
      .where(inArray(schema.userPreferences.userId, memberIds));

    const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))];
    const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))];
    const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
    const myPrefs = allPrefs.find((p) => p.userId === req.userId);

    // --- 3. Kandidater: publicerade recept för måltidstypen (eller taggen) ---
    // Baka-fliken skickar tag=baking i stället för en måltidstyp.
    // Felstavade kocknamn (t.ex. "remsey") mappas via alias till rätt kock.
    const chefMatches = q.search ? resolveChefAliases(q.search) : [];
    const candidates = await app.db
      .select()
      .from(schema.recipes)
      .where(
        and(
          eq(schema.recipes.status, "published"),
          // Titelsök (t.ex. "pannkaka") söker BLAND ALLA recept, oavsett flik.
          // Endast TITELN (inte beskrivningen) för relevans, och trigram-
          // similarity gör söket stavningstolerant ("pankakor" hittar pannkakor).
          // Utan sökterm filtreras i stället på flikens tagg/måltidstyp.
          q.search
            ? or(
                ilike(schema.recipes.titleSv, `%${q.search}%`),
                sql`similarity(${schema.recipes.titleSv}, ${q.search}) > 0.3`,
                // Högtidsnamn: "julafton"/"midsommar" → recept taggade med en högtid
                // vars slug eller namn ryms i söktermen (man slipper kunna exakt slug).
                sql`EXISTS (SELECT 1 FROM season_events se WHERE se.slug = ANY(${schema.recipes.holidayTags}) AND (lower(${q.search}) LIKE '%' || lower(se.slug) || '%' OR lower(${q.search}) LIKE '%' || lower(se.name_sv) || '%'))`,
                // Ingrediens: "lax"/"kyckling" → recept som innehåller en matchande ingrediens.
                sql`EXISTS (SELECT 1 FROM recipe_ingredients ri JOIN canonical_ingredients ci ON ci.id = ri.canonical_ingredient_id WHERE ri.recipe_id = ${schema.recipes.id} AND ci.name_sv ILIKE ${"%" + q.search + "%"})`,
                // Kock/källa: "ramsay"/"ottolenghi" → recept krediterade den kocken.
                ilike(schema.recipes.creatorDisplayName, `%${q.search}%`),
                // Felstavat kocknamn via alias ("remsey" → Gordon Ramsay).
                chefMatches.length
                  ? inArray(schema.recipes.creatorDisplayName, chefMatches)
                  : sql`false`,
                // Fuzzy kocknamn (fångar t.ex. "ramzay"/"otolengi" utan aliaslista).
                sql`word_similarity(${q.search}, ${schema.recipes.creatorDisplayName}) > 0.42`,
              )
            : q.holiday
              ? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})`
              : q.tag
                ? sql`${q.tag} = ANY(${schema.recipes.tags})`
                : sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
        ),
      )
      .limit(200);

    const allIngredients = await app.db
      .select()
      .from(schema.recipeIngredients)
      .where(
        inArray(
          schema.recipeIngredients.recipeId,
          candidates.map((c) => c.id),
        ),
      );
    const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))];
    const safetyRows = await app.db
      .select()
      .from(schema.canonicalIngredients)
      .where(inArray(schema.canonicalIngredients.id, ingredientIds));
    const safetyMap = new Map<string, IngredientSafetyInfo>(
      safetyRows.map((r) => [
        r.id,
        {
          id: r.id,
          allergens: r.allergens,
          isVegan: r.isVegan,
          isVegetarian: r.isVegetarian,
          containsGluten: r.containsGluten,
          containsLactose: r.containsLactose,
          isPork: r.isPork,
          isBeef: r.isBeef,
          isAlcohol: r.isAlcohol,
        },
      ]),
    );
    for (const r of safetyRows) {
      if (!unitInfo.has(r.id)) {
        unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
      }
    }

    // --- 4. Näringskontext: vad återstår av dagen? ---
    const [profile] = await app.db
      .select()
      .from(schema.userHealthProfiles)
      .where(eq(schema.userHealthProfiles.userId, req.userId))
      .limit(1);
    const targets =
      profile?.weightKg && profile.heightCm && profile.birthYear
        ? computeDailyTargets({
            sex: profile.sex ?? "unspecified",
            age: today.getUTCFullYear() - profile.birthYear,
            heightCm: profile.heightCm,
            weightKg: profile.weightKg,
            activityLevel: profile.activityLevel,
            primaryGoal: myPrefs?.primaryGoal ?? undefined,
          }).targets
        : DEFAULT_TARGETS;
    const todaysMeals = await app.db
      .select({ nutrition: schema.meals.nutrition })
      .from(schema.meals)
      .where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso())));
    const daySummary = summarizeDay(
      todaysMeals.map((m) => m.nutrition),
      targets,
    );

    // --- 5. Säsong & högtid (spec §28) – marknadsstyrt via användarens region ---
    const localePrefs = await loadLocalePreferences(app.db, req.userId);
    const market = localePrefs.regionCode;
    const events = await app.db
      .select()
      .from(schema.seasonEvents)
      .where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, market)));
    const activeEvents = events.filter((e) =>
      isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today),
    );
    const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring)

    // --- 6. Senast lagat (variation) + hushållsbetyg ---
    const cooks = householdId
      ? await app.db
          .select({
            recipeId: schema.recipeCooks.recipeId,
            last: sql<string>`max(${schema.recipeCooks.cookedAt})`,
          })
          .from(schema.recipeCooks)
          .where(eq(schema.recipeCooks.householdId, householdId))
          .groupBy(schema.recipeCooks.recipeId)
      : [];
    const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last]));
    const householdRatings = await app.db
      .select({
        recipeId: schema.recipeRatings.recipeId,
        avg: sql<number>`avg(${schema.recipeRatings.stars})`,
      })
      .from(schema.recipeRatings)
      .where(inArray(schema.recipeRatings.userId, memberIds))
      .groupBy(schema.recipeRatings.recipeId);
    const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));

    // --- 7. Personaliseringssamtycke — HÅRD GRIND (S1) ---
    const [personalizationConsent] = await app.db
      .select()
      .from(schema.userConsents)
      .where(
        and(
          eq(schema.userConsents.userId, req.userId),
          eq(schema.userConsents.kind, "personalization"),
        ),
      )
      .limit(1);
    const personalizationEnabled = personalizationConsent?.status === "granted";

    let memoryItems: MemoryItem[] = [];
    let tasteSignals: TasteSignal[] = [];
    let cookingAssumptions: CookingAssumption[] = [];

    if (personalizationEnabled && householdId) {
      const memoryRows = await app.db
        .select({
          id: schema.memoryItems.id,
          userId: schema.memoryItems.userId,
          householdId: schema.memoryItems.householdId,
          kind: schema.memoryItems.kind,
          key: schema.memoryItems.key,
          summarySv: schema.memoryItems.summarySv,
          value: schema.memoryItems.value,
          origin: schema.memoryItems.origin,
          confidence: schema.memoryItems.confidence,
          verifiedByUser: schema.memoryItems.verifiedByUser,
          paused: schema.memoryItems.paused,
          createdAt: schema.memoryItems.createdAt,
          updatedAt: schema.memoryItems.updatedAt,
        })
        .from(schema.memoryItems)
        .where(
          or(
            eq(schema.memoryItems.userId, req.userId),
            eq(schema.memoryItems.householdId, householdId),
          ),
        );
      memoryItems = memoryRows.map((m) => ({
        ...m,
        userId: m.userId ?? undefined,
        householdId: m.householdId ?? undefined,
        createdAt: m.createdAt.toISOString(),
        updatedAt: m.updatedAt.toISOString(),
        lastUsedAt: undefined,
        expiresAt: undefined,
      }));

      const tasteRows = await app.db
        .select({
          id: schema.tasteSignals.id,
          userId: schema.tasteSignals.userId,
          axis: schema.tasteSignals.axis,
          target: schema.tasteSignals.target,
          direction: schema.tasteSignals.direction,
          strength: schema.tasteSignals.strength,
          origin: schema.tasteSignals.origin,
          refRecipeId: schema.tasteSignals.refRecipeId,
          createdAt: schema.tasteSignals.createdAt,
        })
        .from(schema.tasteSignals)
        .where(eq(schema.tasteSignals.userId, req.userId));
      tasteSignals = tasteRows.map((t) => ({
        ...t,
        target: t.target ?? undefined,
        refRecipeId: t.refRecipeId ?? undefined,
        createdAt: t.createdAt.toISOString(),
      }));

      const assumptions = await app.db
        .select({
          canonicalIngredientId: schema.cookingAssumptionProfiles.canonicalIngredientId,
          averageEatenPortions: schema.cookingAssumptionProfiles.averageEatenPortions,
          averageLeftoverPortions: schema.cookingAssumptionProfiles.averageLeftoverPortions,
          observationCount: schema.cookingAssumptionProfiles.observationCount,
        })
        .from(schema.cookingAssumptionProfiles)
        .where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
      cookingAssumptions = assumptions.map((a) => ({
        canonicalIngredientId: a.canonicalIngredientId,
        averageEatenPortions: a.averageEatenPortions,
        averageLeftoverPortions: a.averageLeftoverPortions,
        observationCount: a.observationCount,
      }));
    }

    // --- 8. Tolka "jag är sugen på" (spec §19) ---
    const craving = q.craving ? parseCraving(q.craving) : null;

    // Uttrycklig avsikt: användaren har valt en högtid, en tagg (t.ex. Baka)
    // eller sökt på namn. Då ska hushållets vardags-tidsgräns INTE hårt gömma
    // maten de faktiskt bett om (spec §28) – en kräftskiva eller ett bak tar
    // tid. Tiden blir mjuk rankning i stället för diskvalificerande filter.
    // En uttrycklig tidsgräns i förfrågan (q.maxMinutes) respekteras alltid.
    // "swap" räknas som uttrycklig avsikt: byt rätt ska inte hårt gömmas av
    // hushållets vardags-tidsgräns, annars kvar bara några få snabba rätter.
    const hasExplicitIntent = !!(q.holiday || q.tag || q.search || q.view === "swap");

    const ctx: RecommendationContext = {
      mealType: q.mealType,
      persons: q.persons ?? (members.length ? members.length : 1),
      maxMinutes:
        q.maxMinutes ??
        (hasExplicitIntent ? undefined : (myPrefs?.maxCookingMinutesWeekday ?? undefined)),
      maxCostMinorPerPortion: q.maxCostMinorPerPortion,
      remainingProteinG: Math.max(0, daySummary.remaining.proteinG),
      remainingKcal: Math.max(0, daySummary.remaining.kcal),
      currentSeason: seasonForDate(today),
      activeHolidayTags,
      isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4,
      favoriteCuisines: myPrefs?.favoriteCuisines ?? [],
      dietPattern: myPrefs?.dietPattern ?? undefined,
      cravingTags: craving?.tags,
      cravingCuisine: craving?.cuisine,
      cravingMaxKcal: craving?.maxKcal,
      personalizationEnabled,
      memoryItems: personalizationEnabled ? memoryItems : undefined,
      tasteSignals: personalizationEnabled ? tasteSignals : undefined,
      cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined,
    };

    // --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
    const scoredCandidates: RecommendationCandidate[] = [];
    for (const recipe of candidates) {
      const recipeIngredients = allIngredients
        .filter((i) => i.recipeId === recipe.id)
        .map((i) => ({
          canonicalIngredientId: i.canonicalIngredientId,
          displayNameSv: i.displayNameSv,
          quantity: i.quantity,
          unit: i.unit,
          optional: i.optional,
        }));

      const violations = checkRecipeSafety(
        {
          ingredients: recipeIngredients.map((i) => ({
            canonicalIngredientId: i.canonicalIngredientId,
            optional: i.optional,
          })),
          spiceLevel: recipe.spiceLevel,
        },
        {
          allergens: combinedAllergens,
          dietPattern: myPrefs?.dietPattern,
          religiousRule: myPrefs?.religiousRule,
          avoidIngredientIds: combinedAvoid,
          spiceLevelMax: strictestSpice,
        },
        safetyMap,
      );
      if (!isRecipeSafe(violations)) continue;

      const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today);
      const lastDate = lastCooked.get(recipe.id);
      scoredCandidates.push({
        recipeId: recipe.id,
        titleSv: recipe.titleSv,
        cuisine: recipe.cuisine,
        tags: recipe.tags as never,
        totalTimeMinutes: recipe.totalTimeMinutes,
        nutritionPerPortion: recipe.nutritionPerPortion,
        estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion,
        ratingAverage: recipe.ratingAverage,
        ratingCount: recipe.ratingCount,
        peakSeasons: recipe.peakSeasons,
        holidayTags: recipe.holidayTags,
        mealTypes: recipe.mealTypes,
        spiceLevel: recipe.spiceLevel,
        coverage,
        daysSinceLastCooked: lastDate
          ? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
          : null,
        householdRating: householdRatingMap.get(recipe.id) ?? null,
        ingredientIds: recipeIngredients
          .map((i) => i.canonicalIngredientId)
          .filter((id): id is string => id != null),
      });
    }

    const weights = personalizationEnabled
      ? viewWeights(q.view)
      : depersonalize(viewWeights(q.view));

    // Högtids-sök: matchar sökningen ett event för marknaden (året runt) → expandera till dess foodTags + slug.
    const holidayFoodTags: string[] = [];
    const holidaySlugs: string[] = [];
    if (q.craving) {
      const qc = q.craving.toLowerCase();
      for (const e of events) {
        if (
          qc.includes(e.slug) ||
          qc.includes(e.nameSv.toLowerCase()) ||
          (e.nameLocalized ? qc.includes(e.nameLocalized.toLowerCase()) : false)
        ) {
          holidayFoodTags.push(...e.foodTags);
          holidaySlugs.push(e.slug);
        }
      }
    }

    // Sökte användaren specifikt (råvara "kyckling" ELLER högtid "midsommar")? Visa matchande först.
    let pool = scoredCandidates;
    if (craving && (craving.keywords.length > 0 || craving.cuisine || holidaySlugs.length > 0)) {
      const matches = scoredCandidates.filter((c) => {
        const title = c.titleSv.toLowerCase();
        const tagSet = new Set<string>(c.tags as unknown as string[]);
        return (
          (!!craving.cuisine && c.cuisine === craving.cuisine) ||
          craving.keywords.some((k) => title.includes(k) || tagSet.has(k)) ||
          holidaySlugs.some((s) => c.holidayTags.includes(s)) ||
          holidayFoodTags.some((ft) => title.includes(ft) || tagSet.has(ft))
        );
      });
      if (matches.length > 0) pool = matches; // annars behåll alla (aldrig tomt)
    }

    let recommendations = rankAll(pool, ctx, weights, q.limit);

    // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
    if (await app.flags.isEnabled("ai_rerank", req.userId)) {
      const result = await app.aamos.runTask(
        "RANK_RECIPES",
        {
          candidateIds: recommendations.map((r) => r.recipeId),
          deterministicScores: Object.fromEntries(
            recommendations.map((r) => [r.recipeId, r.score]),
          ),
          contextSummary: summarizeContext(ctx),
        },
        {
          correlationId: req.correlationId,
          subjectRef: null,
          localeContext: await (
            await import("../lib/localeContext.js")
          ).getLocaleContext(app.db, req.userId),
        },
      );
      if (result.status === "ok" && result.output) {
        const order = new Map(result.output.rankedIds.map((id, i) => [id, i]));
        recommendations = [...recommendations].sort(
          (a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99),
        );
      }
    }

    // --- 10. Matlådor först när rimligt (spec §24) ---
    const mealBoxes =
      q.includeLeftovers && householdId
        ? await app.db
            .select()
            .from(schema.mealBoxes)
            .where(
              and(
                eq(schema.mealBoxes.householdId, householdId),
                eq(schema.mealBoxes.status, "available"),
              ),
            )
            .orderBy(schema.mealBoxes.recommendedUseBy)
            .limit(5)
        : [];
    const mealBoxSuggestions = mealBoxes.map((box) => ({
      mealBoxId: box.id,
      titleSv: box.titleSv,
      portionsRemaining: box.portionsRemaining,
      recommendedUseBy: box.recommendedUseBy,
      whySv:
        Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000
          ? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.`
          : "Färdig mat som väntar – snabbaste middagen i huset.",
    }));

    // Betyg (stjärnor) finns på kandidaterna men följer INTE med i
    // ScoredRecommendation som rankAll returnerar. Återkoppla dem per recept så
    // mobilen kan visa "★ 4,5 · 12" i listan (annars syns aldrig satta betyg).
    const ratingByRecipe = new Map(
      scoredCandidates.map((c) => [
        c.recipeId,
        { average: c.ratingAverage, count: c.ratingCount },
      ]),
    );

    // "Menade du …?" – bara när en sökning gav noll träffar (grov felstavning).
    const searchSuggestion =
      q.search && recommendations.length === 0
        ? await computeSearchSuggestion(app, q.search)
        : null;

    // --- i18n: översätt titlar + ingrediensnamn till användarens språk (svensk källa = fallback) ---
    const languageTag = localePrefs.languageTag;
    const titleByRecipe = await resolveRecipeTitles(
      app.db,
      recommendations.map((r) => r.recipeId),
      languageTag,
    );
    const localizedById = await resolveIngredientNames(
      app.db,
      allIngredients
        .map((i) => i.canonicalIngredientId)
        .filter((id): id is string => id != null),
      languageTag,
    );
    const svToLocalized = new Map<string, string>();
    for (const i of allIngredients) {
      const loc = i.canonicalIngredientId ? localizedById.get(i.canonicalIngredientId) : undefined;
      if (loc) svToLocalized.set(i.displayNameSv, loc);
    }
    const localizeName = (n: string): string => svToLocalized.get(n) ?? n;

    return {
      mealType: q.mealType,
      context: {
        persons: ctx.persons,
        season: ctx.currentSeason,
        activeHolidays: activeEvents.map((e) => ({
          slug: e.slug,
          name: e.nameLocalized || e.nameSv,
        })),
        remainingKcal: ctx.remainingKcal,
        remainingProteinG: ctx.remainingProteinG,
        craving: craving ?? null,
        view: q.view,
        searchSuggestion,
      },
      mealBoxSuggestions,
      recommendations: recommendations.map((r) => ({
        ...r,
        title: titleByRecipe.get(r.recipeId) ?? r.titleSv,
        usesExpiring: r.usesExpiring.map((u) => ({ ...u, name: localizeName(u.nameSv) })),
        missingIngredients: r.missingIngredients.map(localizeName),
        ratingAverage: ratingByRecipe.get(r.recipeId)?.average ?? null,
        ratingCount: ratingByRecipe.get(r.recipeId)?.count ?? 0,
      })),
    };
  });
}
