import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
  cookRecipeInputSchema,
  createUserRecipeInputSchema,
  idParamSchema,
  rateRecipeInputSchema,
  recipeQuerySchema,
  substitutionQuerySchema,
} from "@app/validation";
import {
  checkRecipeSafety,
  deriveRecipeAllergens,
  scaleIngredients,
  type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import {
  languageCandidates,
  resolveIngredientNames,
  resolveRecipeTranslation,
  userLanguageTag,
} from "../lib/contentLanguage.js";
import {
  emitEvent,
  getActiveHouseholdId,
  requireActiveHousehold,
  todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";

/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
  const auth = { preHandler: [app.authenticate] };

  app.get("/v1/recipes", auth, async (req) => {
    const q = parse(recipeQuerySchema, req.query);

    const conditions = [eq(schema.recipes.status, "published")];
    if (q.search) {
      conditions.push(
        or(
          ilike(schema.recipes.titleSv, `%${q.search}%`),
          ilike(schema.recipes.descriptionSv, `%${q.search}%`),
        )!,
      );
    }
    if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine));
    if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`);
    if (q.tags)
      for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`);
    if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`);
    if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes));
    if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty));
    if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId));
    if (q.maxKcalPerPortion) {
      conditions.push(
        sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`,
      );
    }
    if (q.minProteinPerPortion) {
      conditions.push(
        sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`,
      );
    }
    if (q.maxCostMinorPerPortion) {
      conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion));
    }
    // Deterministisk allergifiltrering på databasnivå (spec §61.2)
    if (q.excludeAllergens) {
      for (const allergen of q.excludeAllergens) {
        conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`);
      }
    }

    const orderBy =
      q.sort === "rating"
        ? desc(schema.recipes.ratingAverage)
        : q.sort === "cooked"
          ? desc(schema.recipes.cookCount)
          : q.sort === "newest"
            ? desc(schema.recipes.createdAt)
            : q.sort === "time"
              ? schema.recipes.totalTimeMinutes
              : q.sort === "cost"
                ? schema.recipes.estimatedCostMinorPerPortion
                : desc(schema.recipes.cookCount);

    const rows = await app.db
      .select({
        id: schema.recipes.id,
        slug: schema.recipes.slug,
        titleSv: schema.recipes.titleSv,
        descriptionSv: schema.recipes.descriptionSv,
        cuisine: schema.recipes.cuisine,
        mealTypes: schema.recipes.mealTypes,
        tags: schema.recipes.tags,
        totalTimeMinutes: schema.recipes.totalTimeMinutes,
        portions: schema.recipes.portions,
        nutritionPerPortion: schema.recipes.nutritionPerPortion,
        allergens: schema.recipes.allergens,
        spiceLevel: schema.recipes.spiceLevel,
        estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion,
        costCurrency: sql<string>`'SEK'`,
        difficulty: schema.recipes.difficulty,
        imageUrls: schema.recipes.imageUrls,
        ratingAverage: schema.recipes.ratingAverage,
        ratingCount: schema.recipes.ratingCount,
        cookCount: schema.recipes.cookCount,
        verificationStatus: schema.recipes.verificationStatus,
        creatorDisplayName: schema.recipes.creatorDisplayName,
        variantType: schema.recipes.variantType,
      })
      .from(schema.recipes)
      .where(and(...conditions))
      .orderBy(orderBy)
      .limit(q.limit)
      .offset(q.offset);

    // Titlar på användarens språk där publicerad översättning finns (i18n M3).
    const languageTag = await userLanguageTag(app.db, req.userId);
    const candidates = languageCandidates(languageTag);
    let titleMap = new Map<string, string>();
    if (!candidates.includes("sv") && rows.length > 0) {
      const translations = await app.db
        .select({
          recipeId: schema.recipeTranslations.recipeId,
          languageTag: schema.recipeTranslations.languageTag,
          title: schema.recipeTranslations.title,
        })
        .from(schema.recipeTranslations)
        .where(
          and(
            inArray(
              schema.recipeTranslations.recipeId,
              rows.map((r) => r.id),
            ),
            inArray(schema.recipeTranslations.languageTag, candidates),
            eq(schema.recipeTranslations.status, "published"),
          ),
        );
      for (const candidate of [...candidates].reverse()) {
        for (const tr of translations)
          if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title);
      }
    }

    return {
      recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
      language: candidates.includes("sv") ? "sv" : languageTag,
    };
  });

  app.get("/v1/recipes/:id", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    const recipe = await loadFullRecipe(app, id);

    // Personlig säkerhetskontroll – deterministisk (spec §61.2).
    const [prefs] = await app.db
      .select()
      .from(schema.userPreferences)
      .where(eq(schema.userPreferences.userId, req.userId))
      .limit(1);
    let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] };
    if (prefs) {
      const info = await ingredientSafetyMap(
        app,
        recipe.ingredients.map((i) => i.canonicalIngredientId),
      );
      const violations = checkRecipeSafety(
        {
          ingredients: recipe.ingredients.map((i) => ({
            canonicalIngredientId: i.canonicalIngredientId,
            optional: i.optional,
          })),
          spiceLevel: recipe.spiceLevel,
        },
        {
          allergens: prefs.allergens,
          dietPattern: prefs.dietPattern,
          religiousRule: prefs.religiousRule,
          avoidIngredientIds: prefs.avoidIngredientIds,
          spiceLevelMax: prefs.spiceLevelMax,
        },
        info,
      );
      safety = { safe: !violations.some((v) => v.severity === "blocker"), violations };
    }

    // Varianter (spec §16)
    const variants = await app.db
      .select({
        id: schema.recipes.id,
        titleSv: schema.recipes.titleSv,
        variantType: schema.recipes.variantType,
      })
      .from(schema.recipes)
      .where(
        and(
          or(
            eq(schema.recipes.variantOfRecipeId, id),
            recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`,
          ),
          eq(schema.recipes.status, "published"),
        ),
      );

    const [myRating] = await app.db
      .select()
      .from(schema.recipeRatings)
      .where(
        and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)),
      )
      .limit(1);
    const [favorite] = await app.db
      .select()
      .from(schema.recipeFavorites)
      .where(
        and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
      )
      .limit(1);

    // Innehållsspråk (i18n-spec §13–14): publicerad översättning om användarens
    // språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
    const languageTag = await userLanguageTag(app.db, req.userId);
    const translation = await resolveRecipeTranslation(app.db, id, languageTag);
    const ingredientNames = await resolveIngredientNames(
      app.db,
      recipe.ingredients.map((i) => i.canonicalIngredientId),
      languageTag,
    );

    return {
      ...recipe,
      language: translation?.language ?? "sv",
      title: translation?.title ?? recipe.titleSv,
      description: translation ? translation.description : recipe.descriptionSv,
      storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv,
      ingredients: recipe.ingredients.map((i) => ({
        ...i,
        displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
      })),
      steps: recipe.steps.map((s) => {
        const ts = translation?.steps.get(s.stepNumber);
        return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
      }),
      safety,
      variants,
      myRating: myRating ?? null,
      isFavorite: Boolean(favorite),
    };
  });

  /** Skala recept (Cooking Mode: "skala till sex personer"). */
  app.get("/v1/recipes/:id/scaled", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    const portions = Number((req.query as { portions?: string }).portions ?? 4);
    if (!Number.isInteger(portions) || portions < 1 || portions > 24) {
      throw errors.badRequest("portions måste vara 1–24.");
    }
    const recipe = await loadFullRecipe(app, id);
    const scaled = scaleIngredients(
      recipe.ingredients.map((i) => ({
        canonicalIngredientId: i.canonicalIngredientId,
        displayNameSv: i.displayNameSv,
        quantity: i.quantity,
        unit: i.unit,
        optional: i.optional,
      })),
      recipe.portions,
      portions,
    );
    return {
      recipeId: id,
      portions,
      ingredients: scaled,
      nutritionPerPortion: recipe.nutritionPerPortion,
    };
  });

  app.post("/v1/recipes/:id/rate", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    const input = parse(rateRecipeInputSchema, req.body);
    await loadFullRecipe(app, id);

    await app.db
      .insert(schema.recipeRatings)
      .values({
        recipeId: id,
        userId: req.userId,
        stars: input.stars,
        feedbackTags: input.feedbackTags,
        comment: input.comment ?? null,
      })
      .onConflictDoUpdate({
        target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId],
        set: {
          stars: input.stars,
          feedbackTags: input.feedbackTags,
          comment: input.comment ?? null,
          updatedAt: new Date(),
        },
      });

    // Uppdatera aggregat
    const [agg] = await app.db
      .select({
        avg: sql<number>`avg(${schema.recipeRatings.stars})`,
        count: sql<number>`count(*)`,
      })
      .from(schema.recipeRatings)
      .where(eq(schema.recipeRatings.recipeId, id));
    await app.db
      .update(schema.recipes)
      .set({
        ratingAverage: agg ? Number(agg.avg) : null,
        ratingCount: agg ? Number(agg.count) : 0,
      })
      .where(eq(schema.recipes.id, id));

    // Smaksignaler ur feedback (spec §30) – explicit användarsignal.
    const tagToAxis: Record<
      string,
      { axis: "spice" | "salt" | "acid" | "creaminess"; dir: number }
    > = {
      too_spicy: { axis: "spice", dir: -1 },
      too_mild: { axis: "spice", dir: 1 },
      too_salty: { axis: "salt", dir: -1 },
      too_sour: { axis: "acid", dir: -1 },
      too_little_sauce: { axis: "creaminess", dir: 1 },
    };
    for (const tag of input.feedbackTags) {
      const mapping = tagToAxis[tag];
      if (mapping) {
        await app.db.insert(schema.tasteSignals).values({
          userId: req.userId,
          axis: mapping.axis,
          direction: mapping.dir,
          strength: 0.7,
          origin: "user_stated",
          refRecipeId: id,
        });
      }
    }

    await emitEvent(app.db, {
      type: "RECIPE_RATED",
      payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags },
      userId: req.userId,
      correlationId: req.correlationId,
    });
    return { ok: true };
  });

  app.post("/v1/recipes/:id/favorite", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    await app.db
      .insert(schema.recipeFavorites)
      .values({ recipeId: id, userId: req.userId })
      .onConflictDoNothing();
    await app.db
      .update(schema.recipes)
      .set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
      .where(eq(schema.recipes.id, id));
    return { ok: true };
  });

  app.delete("/v1/recipes/:id/favorite", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    await app.db
      .delete(schema.recipeFavorites)
      .where(
        and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
      );
    await app.db
      .update(schema.recipes)
      .set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` })
      .where(eq(schema.recipes.id, id));
    return { ok: true };
  });

  app.get("/v1/recipes/favorites/mine", auth, async (req) => {
    const rows = await app.db
      .select({
        id: schema.recipes.id,
        titleSv: schema.recipes.titleSv,
        totalTimeMinutes: schema.recipes.totalTimeMinutes,
        nutritionPerPortion: schema.recipes.nutritionPerPortion,
        imageUrls: schema.recipes.imageUrls,
      })
      .from(schema.recipeFavorites)
      .innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
      .where(eq(schema.recipeFavorites.userId, req.userId))
      .orderBy(desc(schema.recipeFavorites.createdAt));
    return { recipes: rows };
  });

  /**
   * "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO),
   * måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen.
   */
  app.post("/v1/recipes/:id/cook", auth, async (req) => {
    const { id } = parse(idParamSchema, req.params);
    const input = parse(cookRecipeInputSchema, req.body);
    const recipe = await loadFullRecipe(app, id);
    const householdId = await requireActiveHousehold(app.db, req.userId);
    const date = input.date ?? todayIso();

    // 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum)
    const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
    if (input.deductInventory) {
      const factor = input.portionsCooked / recipe.portions;
      const overrides = new Map(input.inventoryOverrides.map((o) => [o.canonicalIngredientId, o]));

      for (const ing of recipe.ingredients) {
        if (ing.optional) continue;
        const override = overrides.get(ing.canonicalIngredientId);
        const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
        const requiredUnit = override ? override.unit : ing.unit;
        if (requiredQty <= 0) continue;

        const stock = await app.db
          .select()
          .from(schema.inventoryItems)
          .where(
            and(
              eq(schema.inventoryItems.householdId, householdId),
              eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
              isNull(schema.inventoryItems.depletedAt),
              gt(schema.inventoryItems.quantity, 0),
            ),
          );
        if (stock.length === 0) continue;

        const [info] = await app.db
          .select()
          .from(schema.canonicalIngredients)
          .where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
          .limit(1);

        const allocation = allocateFefo(
          requiredQty,
          requiredUnit,
          stock.map((s) => ({
            id: s.id,
            canonicalIngredientId: s.canonicalIngredientId,
            quantity: s.quantity,
            unit: s.unit,
            bestBeforeDate: s.bestBeforeDate,
            useByDate: s.useByDate,
            openedAt: s.openedAt,
            frozenAt: s.frozenAt,
            thawedAt: s.thawedAt,
            purchasedAt: s.purchasedAt,
          })),
          { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
        );

        for (const alloc of allocation.allocations) {
          const item = stock.find((s) => s.id === alloc.itemId)!;
          const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
          await app.db.insert(schema.inventoryTransactions).values({
            householdId,
            inventoryItemId: alloc.itemId,
            type: "cook_use",
            quantityDelta: -(item.quantity - newQty),
            unit: item.unit,
            refType: "recipe_cook",
            refId: id,
            actorUserId: req.userId,
          });
          await app.db
            .update(schema.inventoryItems)
            .set({
              quantity: newQty,
              depletedAt: newQty <= 0 ? new Date() : null,
              updatedAt: new Date(),
            })
            .where(eq(schema.inventoryItems.id, alloc.itemId));
          deductions.push({
            itemId: alloc.itemId,
            quantity: alloc.quantity,
            unit: alloc.unit,
            name: item.displayName,
          });
        }
      }
    }

    // 2. Logga måltid per ätare med portionsandel (spec §7: individuellt)
    const eaters =
      input.eaters.length > 0 ? input.eaters : [{ userId: req.userId, portionFraction: 1 }];
    const mealIds: string[] = [];
    for (const eater of eaters) {
      const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
      const [meal] = await app.db
        .insert(schema.meals)
        .values({
          userId: eater.userId,
          householdId,
          date,
          mealType: input.mealType,
          source: "cooked_recipe",
          recipeId: id,
          titleSv: recipe.titleSv,
          portionFraction: eater.portionFraction,
          nutrition,
          nutritionIsEstimate: false,
        })
        .returning();
      mealIds.push(meal!.id);
      await emitEvent(app.db, {
        type: "MEAL_LOGGED",
        payload: {
          mealId: meal!.id,
          mealType: input.mealType,
          kcal: nutrition.kcal,
          source: "cooked_recipe",
        },
        userId: eater.userId,
        householdId,
        correlationId: req.correlationId,
      });
    }

    // 3. Matlådor (spec §24)
    let mealBoxId: string | null = null;
    if (input.mealBoxPortions > 0) {
      const locationId =
        input.mealBoxStorageLocationId ??
        (
          await app.db
            .select({ id: schema.storageLocations.id })
            .from(schema.storageLocations)
            .where(
              and(
                eq(schema.storageLocations.householdId, householdId),
                eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
              ),
            )
            .limit(1)
        )[0]?.id;
      if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");

      const useByDays = input.mealBoxFrozen ? 90 : 3;
      const recommendedUseBy = new Date(Date.now() + useByDays * 86_400_000)
        .toISOString()
        .slice(0, 10);
      const [box] = await app.db
        .insert(schema.mealBoxes)
        .values({
          householdId,
          recipeId: id,
          titleSv: recipe.titleSv,
          portions: input.mealBoxPortions,
          portionsRemaining: input.mealBoxPortions,
          nutritionPerPortion: recipe.nutritionPerPortion,
          cookedAt: date,
          storageLocationId: locationId,
          frozen: input.mealBoxFrozen,
          recommendedUseBy,
        })
        .returning();
      mealBoxId = box!.id;
      await emitEvent(app.db, {
        type: "MEAL_BOX_CREATED",
        payload: { mealBoxId: box!.id, portions: input.mealBoxPortions },
        userId: req.userId,
        householdId,
        correlationId: req.correlationId,
      });
    }

    // 4. Statistik + event
    await app.db.insert(schema.recipeCooks).values({
      recipeId: id,
      userId: req.userId,
      householdId,
      portionsCooked: input.portionsCooked,
    });
    await app.db
      .update(schema.recipes)
      .set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
      .where(eq(schema.recipes.id, id));
    await emitEvent(app.db, {
      type: "RECIPE_COOKED",
      payload: {
        recipeId: id,
        portions: input.portionsCooked,
        mealBoxPortions: input.mealBoxPortions,
      },
      userId: req.userId,
      householdId,
      correlationId: req.correlationId,
    });

    return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
  });

  /** Substitutionsförslag för en ingrediens (spec §20). */
  app.get("/v1/substitutions", auth, async (req) => {
    const q = parse(substitutionQuerySchema, req.query);
    const subs = await app.db
      .select({
        sub: schema.substitutions,
        toName: schema.canonicalIngredients.nameSv,
      })
      .from(schema.substitutions)
      .innerJoin(
        schema.canonicalIngredients,
        eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id),
      )
      .where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
      .orderBy(desc(schema.substitutions.priority));
    return {
      substitutions: subs.map((s) => ({
        ...s.sub,
        toNameSv: s.toName,
        contextWarning:
          q.context && s.sub.notRecommendedFor.includes(q.context)
            ? `Rekommenderas inte för ${q.context}.`
            : null,
      })),
    };
  });

  /**
   * Ingrediens-sök (manuell registrering, spec §9; i18n M2).
   * Söker i svenska namn/alias OCH i publicerade översättningar för
   * användarens språk; svaret bär namn upplöst till användarens språk.
   */
  app.get("/v1/ingredients", auth, async (req) => {
    const search = String((req.query as { search?: string }).search ?? "").trim();
    const languageTag = await userLanguageTag(app.db, req.userId);
    const candidates = languageCandidates(languageTag);
    // i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm).
    // "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar".
    const pattern = `%${search}%`;
    const translationMatch = sql`EXISTS (
      SELECT 1 FROM ingredient_translations it
      WHERE it.ingredient_id = ${schema.canonicalIngredients.id}
        AND it.status = 'published'
        AND it.language_tag IN ${candidates}
        AND (unaccent(it.name) ILIKE unaccent(${pattern})
             OR similarity(it.name, ${search}) > 0.35
             OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern})))
    )`;
    const conditions = search
      ? or(
          sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`,
          sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`,
          sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`,
          ...(candidates.includes("sv") ? [] : [translationMatch]),
        )
      : undefined;
    const rows = await app.db
      .select({
        id: schema.canonicalIngredients.id,
        nameSv: schema.canonicalIngredients.nameSv,
        category: schema.canonicalIngredients.category,
        defaultUnit: schema.canonicalIngredients.defaultUnit,
        allergens: schema.canonicalIngredients.allergens,
      })
      .from(schema.canonicalIngredients)
      .where(conditions)
      .orderBy(
        search
          ? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}`
          : schema.canonicalIngredients.nameSv,
      )
      .limit(30);
    const names = await resolveIngredientNames(
      app.db,
      rows.map((r) => r.id),
      languageTag,
    );
    return {
      ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })),
      language: candidates.includes("sv") ? "sv" : languageTag,
    };
  });

  /**
   * Marknadsprofil för näringsvisning + allergenframhävning (i18n M6).
   * Fallback: EU. Styr endast VISNING – säkerhetsfiltrering per användare
   * (spec §61.2) påverkas aldrig av marknadsprofilen.
   */
  app.get("/v1/i18n/nutrition-profile", auth, async (req) => {
    const requested = String((req.query as { region?: string }).region ?? "").toUpperCase();
    const region =
      requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase();
    const [profile] =
      (await app.db
        .select()
        .from(schema.nutritionDisplayProfiles)
        .where(eq(schema.nutritionDisplayProfiles.regionCode, region))
        .limit(1)) ?? [];
    const [fallback] = profile
      ? [profile]
      : await app.db
          .select()
          .from(schema.nutritionDisplayProfiles)
          .where(eq(schema.nutritionDisplayProfiles.regionCode, "EU"))
          .limit(1);
    const effective = fallback!;
    const allergens = await app.db
      .select({ allergen: schema.allergenMarketRules.allergen })
      .from(schema.allergenMarketRules)
      .where(eq(schema.allergenMarketRules.regionCode, effective.regionCode));
    return {
      regionCode: effective.regionCode,
      requestedRegion: region,
      energyDisplay: effective.energyDisplay,
      saltDisplay: effective.saltDisplay,
      energyLabelKey: effective.energyLabelKey,
      highlightAllergens: allergens.map((a) => a.allergen).sort(),
    };
  });

  /** Enhetsetiketter per språk (i18n M2) – för klienter som inte vill hårdkoda. */
  app.get("/v1/i18n/units", auth, async (req) => {
    const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv");
    const candidates = languageCandidates(languageTag);
    const rows = await app.db
      .select()
      .from(schema.unitTranslations)
      .where(inArray(schema.unitTranslations.languageTag, candidates));
    const byUnit = new Map<string, (typeof rows)[number]>();
    for (const candidate of [...candidates].reverse())
      for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r);
    return {
      languageTag,
      units: [...byUnit.values()].map((r) => ({
        unitCode: r.unitCode,
        abbreviation: r.abbreviation,
        name: r.name,
      })),
    };
  });

  /**
   * Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS
   * strukturerar. Näring/allergener beräknas ALLTID deterministiskt här.
   * Publiceringsflöde: submitted → AI-kontroll → moderation → published.
   */
  app.post("/v1/recipes", auth, async (req, reply) => {
    await requireFeature(app.db, req.userId, "communityPublish", "Egna recept");
    const input = parse(createUserRecipeInputSchema, req.body);
    const [user] = await app.db
      .select({ displayName: schema.users.displayName })
      .from(schema.users)
      .where(eq(schema.users.id, req.userId))
      .limit(1);

    let structured: {
      titleSv: string;
      descriptionSv: string;
      ingredients: Array<{
        canonicalIngredientId: string;
        displayNameSv: string;
        quantity: number;
        unit: string;
        optional: boolean;
      }>;
      steps: Array<{
        instructionSv: string;
        timerSeconds?: number | null;
        temperatureC?: number | null;
      }>;
      prepMin: number;
      cookMin: number;
      portions: number;
      cuisine: string;
      mealTypes: string[];
      tags: string[];
      methods: string[];
      equipment: string[];
      difficulty: string;
      spiceLevel: number;
    };

    if (input.mode === "free_text") {
      const result = await app.aamos.runTask(
        "STRUCTURE_RECIPE_TEXT",
        { text: input.text, marketLocale: "sv-SE" },
        {
          correlationId: req.correlationId,
          localeContext: await (
            await import("../lib/localeContext.js")
          ).getLocaleContext(app.db, req.userId),
        },
      );
      if (result.status !== "ok" || !result.output) {
        throw errors.badRequest(
          "Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.",
        );
      }
      const out = result.output;
      const ingredients = out.ingredients
        .filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null)
        .map((i) => ({
          canonicalIngredientId: i.canonicalIngredientId!,
          displayNameSv: i.displayNameSv,
          quantity: i.quantity!,
          unit: i.unit!,
          optional: i.optional,
        }));
      if (ingredients.length === 0) {
        throw errors.badRequest(
          "Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.",
        );
      }
      structured = {
        titleSv: out.titleSv ?? "Mitt recept",
        descriptionSv: out.descriptionSv ?? "",
        ingredients,
        steps: out.steps,
        prepMin: out.prepTimeMinutes ?? 15,
        cookMin: out.cookTimeMinutes ?? 20,
        portions: out.portions ?? 4,
        cuisine: out.suggestedCuisine ?? "international",
        mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"],
        tags: [],
        methods: [],
        equipment: [],
        difficulty: "easy",
        spiceLevel: 0,
      };
    } else {
      structured = {
        titleSv: input.titleSv,
        descriptionSv: input.descriptionSv,
        ingredients: input.ingredients.map((i) => ({
          canonicalIngredientId: i.canonicalIngredientId,
          displayNameSv: i.displayNameSv,
          quantity: i.quantity,
          unit: i.unit,
          optional: i.optional,
        })),
        steps: input.steps.map((s) => ({
          instructionSv: s.instructionSv,
          timerSeconds: s.timerSeconds ?? null,
          temperatureC: s.temperatureC ?? null,
        })),
        prepMin: input.prepTimeMinutes,
        cookMin: input.cookTimeMinutes,
        portions: input.portions,
        cuisine: input.cuisine,
        mealTypes: input.mealTypes,
        tags: input.tags,
        methods: input.methods,
        equipment: input.equipment,
        difficulty: input.difficulty,
        spiceLevel: input.spiceLevel,
      };
    }

    // Deterministisk näring + allergener (spec §61.1–2)
    const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId);
    const dbIngredients = await app.db
      .select()
      .from(schema.canonicalIngredients)
      .where(inArray(schema.canonicalIngredients.id, ingredientIds));
    const sourceMap = new Map(
      dbIngredients.map((i) => [
        i.id,
        {
          nutritionPer100: i.nutritionPer100,
          densityGPerMl: i.densityGPerMl,
          gramsPerPiece: i.gramsPerPiece,
        },
      ]),
    );
    const missing = ingredientIds.filter((id) => !sourceMap.has(id));
    if (missing.length > 0) {
      throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing });
    }
    const nutrition = computeRecipeNutrition(
      structured.ingredients.map((i) => ({
        canonicalIngredientId: i.canonicalIngredientId,
        quantity: i.quantity,
        unit: i.unit as never,
        optional: i.optional,
      })),
      structured.portions,
      sourceMap,
    );
    const safetyInfo = await ingredientSafetyMap(app, ingredientIds);
    const allergens = deriveRecipeAllergens(
      structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId),
      safetyInfo,
    );

    const slug = `${structured.titleSv
      .toLowerCase()
      .replace(/[åä]/g, "a")
      .replace(/ö/g, "o")
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "")
      .slice(0, 60)}-${Date.now().toString(36)}`;

    const [recipe] = await app.db
      .insert(schema.recipes)
      .values({
        slug,
        titleSv: structured.titleSv,
        descriptionSv: structured.descriptionSv,
        cuisine: structured.cuisine as never,
        mealTypes: structured.mealTypes as never,
        tags: structured.tags,
        methods: structured.methods,
        equipment: structured.equipment,
        difficulty: structured.difficulty as never,
        prepTimeMinutes: structured.prepMin,
        cookTimeMinutes: structured.cookMin,
        totalTimeMinutes: structured.prepMin + structured.cookMin,
        portions: structured.portions,
        nutritionPerPortion: nutrition.perPortion,
        allergens,
        spiceLevel: structured.spiceLevel,
        dna: {
          cuisine: structured.cuisine as never,
          vegetables: [],
          flavorProfile: [],
          spiceLevel: structured.spiceLevel,
          method: (structured.methods[0] ?? "stovetop") as never,
          timeMinutes: structured.prepMin + structured.cookMin,
          calories: nutrition.perPortion.kcal,
          proteinGrams: Math.round(nutrition.perPortion.proteinG),
        },
        status: "submitted",
        verificationStatus: "unverified",
        sourceType: "user_generated",
        creatorUserId: req.userId,
        creatorDisplayName: user?.displayName ?? "Okänd",
      })
      .returning();

    await app.db.insert(schema.recipeIngredients).values(
      structured.ingredients.map((i, idx) => ({
        recipeId: recipe!.id,
        canonicalIngredientId: i.canonicalIngredientId,
        displayNameSv: i.displayNameSv,
        quantity: i.quantity,
        unit: i.unit as never,
        optional: i.optional,
        sortOrder: idx,
      })),
    );
    await app.db.insert(schema.recipeSteps).values(
      structured.steps.map((s, idx) => ({
        recipeId: recipe!.id,
        stepNumber: idx + 1,
        instructionSv: s.instructionSv,
        timerSeconds: s.timerSeconds ?? null,
        temperatureC: s.temperatureC ?? null,
      })),
    );

    // AI-kontroll + moderering sker asynkront i workern (spec §35 steg 2–4).
    await app.jobQueue.add("MODERATE_RECIPE", {
      jobType: "MODERATE_RECIPE",
      recipeId: recipe!.id,
      correlationId: req.correlationId,
    });

    await emitEvent(app.db, {
      type: "RECIPE_CREATED",
      payload: { recipeId: recipe!.id, sourceType: "user_generated" },
      userId: req.userId,
      correlationId: req.correlationId,
    });

    return reply.status(201).send({
      recipe,
      uncomputableIngredients: nutrition.uncomputableIngredientIds,
      message: "Receptet är inskickat och granskas innan publicering.",
    });
  });
}

async function loadFullRecipe(app: FastifyInstance, id: string) {
  const [recipe] = await app.db
    .select()
    .from(schema.recipes)
    .where(eq(schema.recipes.id, id))
    .limit(1);
  if (!recipe) throw errors.notFound("Receptet finns inte.");
  const ingredients = await app.db
    .select()
    .from(schema.recipeIngredients)
    .where(eq(schema.recipeIngredients.recipeId, id))
    .orderBy(schema.recipeIngredients.sortOrder);
  const steps = await app.db
    .select()
    .from(schema.recipeSteps)
    .where(eq(schema.recipeSteps.recipeId, id))
    .orderBy(schema.recipeSteps.stepNumber);
  return { ...recipe, ingredients, steps };
}

async function ingredientSafetyMap(
  app: FastifyInstance,
  ids: string[],
): Promise<Map<string, IngredientSafetyInfo>> {
  if (ids.length === 0) return new Map();
  const rows = await app.db
    .select()
    .from(schema.canonicalIngredients)
    .where(inArray(schema.canonicalIngredients.id, ids));
  return new Map(
    rows.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,
      },
    ]),
  );
}
