import type { UnitType } from "@batch-cooking/shared";
import type {
  ParsedRecipe,
  ParsedRecipeIngredient,
  ParsedRecipeStep,
} from "../recipe-sources/recipe-source-adapter.js";
import {
  extractQuantity,
  type IngredientMatchEntry,
  loadIngredientCatalog,
  loadUnitCatalog,
  matchIngredientName,
  matchUnit,
  type UnitMatchEntry,
} from "./ingredient-matcher.js";
import { techStepClassifier } from "./tech-step-matcher.js";

/**
 * The "Traduction en étapes" stage of the import pipeline described in
 * specs/batch-cooking-architecture.md (Import depuis source → **Traduction
 * en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe}
 * (recipe-source-adapter.ts's `parse()` output) and declares each step's
 * technique sequence, the same `techStepIds: number[]` shape
 * `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
 *
 * Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
 * line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
 * the same "free source text -> our catalog id" idea as tech-step
 * detection, just for ingredients/units/quantities instead of technique
 * verbs. Like tech-step matching, this doesn't turn the result into a
 * saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
 * can't know those) — this is one step of the pipeline, not the whole
 * thing.
 *
 * `translateRecipeIngredients` stays pure (takes its matching data as plain
 * arguments, same convention `matchIngredientName` itself has) so it's
 * unit-testable without a database. `translateRecipeSteps` no longer is —
 * technique detection now goes through `techStepClassifier`'s trained
 * model (`tech-step-matcher.ts`), which needs an async call — but is still
 * exported separately from `translateRecipe` for callers/tests that only
 * care about step translation, not ingredients too.
 */

/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
export interface TranslatedRecipeStep extends ParsedRecipeStep {
  /** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
  techStepIds: number[];
}

/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
  ingredientId: number | null;
  unitId: number | null;
}

/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
  steps: TranslatedRecipeStep[];
  ingredients: TranslatedRecipeIngredient[];
}

/**
 * Declares each of `recipe`'s steps' technique sequence for `locale`,
 * leaving everything else about the recipe untouched — including
 * ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
 * shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
 * actually resolving them is {@link translateRecipeIngredients}'s job, kept
 * separate the same way tech-step and ingredient matching are two
 * independent concerns everywhere else in this module. Async — technique
 * detection now runs against `techStepClassifier`'s trained model rather
 * than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
 * can no longer stay a plain synchronous function the way it used to.
 */
export async function translateRecipeSteps(
  recipe: ParsedRecipe,
  locale: string,
): Promise<TranslatedRecipe> {
  try {
    const steps = await Promise.all(
      recipe.steps.map(async (step) => ({
        ...step,
        techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
      })),
    );
    return {
      ...recipe,
      ingredients: recipe.ingredients.map((ingredient) => ({
        ...ingredient,
        ingredientId: null,
        unitId: null,
      })),
      steps,
    };
  } catch (err) {
    // Rethrown as-is — the caller (`sources.service.ts`) already
    // handles/logs failures centrally; this function just isn't allowed a
    // bare `await` per the repo's async/try-catch convention.
    throw err;
  }
}

/**
 * Locale-specific label {@link matchUnit} is fed when a quantity was found
 * but no unit word was — see the `unitId` fallback below. Each is the
 * catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
 * in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
 * `packages/shared`). A locale with neither entry (anything but
 * `"en"`/`"fr"`) falls back to the English spelling — harmless, since
 * `unitCatalog` itself is already empty for an unsupported locale (see
 * `loadUnitCatalog`), so this fallback lookup finds nothing either way.
 */
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
  en: "piece",
  fr: "unité",
};

/**
 * Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
 * against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
 * Pure — testable with hand-built catalogs, no database involved (see
 * `translateRecipe` for the DB-backed loader). `quantity` falls back to
 * `extractQuantity(rawText)` only when the source itself left it `null`;
 * same for `unit` falling back to `extractQuantity`'s `remainder` before
 * being matched against `unitCatalog` — a source that already states a
 * clean unit/quantity is trusted over re-deriving it from `rawText`.
 *
 * When a quantity was found but nothing in the remaining text matched a
 * unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no
 * unit word anywhere in it), `unitId` falls back to the catalog's generic
 * `piece` unit rather than staying `null`: a bare count with no explicit
 * unit word is overwhelmingly "N of them" (eggs, onions, cloves not
 * spelled out as "clove") in practice, not a genuinely missing unit — see
 * issue #53, where this previously left the import review form's submit
 * button disabled with no indication why on almost any recipe with a
 * whole-item ingredient. No fallback when `quantity` itself is `null`
 * (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
 *
 * `locale` (default `"en"`, matching every pre-existing caller) must agree
 * with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
 * (see `loadIngredientCatalog`/`loadUnitCatalog`) — it's threaded through to
 * `matchIngredientName`/`matchUnit` for stemming, and picks the right
 * spelling of the "piece" fallback below.
 */
export function translateRecipeIngredients(
  ingredients: ParsedRecipeIngredient[],
  ingredientCatalog: IngredientMatchEntry[],
  unitCatalog: UnitMatchEntry[],
  locale = "en",
): TranslatedRecipeIngredient[] {
  const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
  return ingredients.map((ingredient) => {
    const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
    const extracted = extractQuantity(ingredient.rawText);
    const quantity = ingredient.quantity ?? extracted.quantity;
    const unitText = ingredient.unit ?? extracted.remainder;
    const unitId =
      matchUnit(unitText, unitCatalog, locale) ??
      (quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
    return { ...ingredient, quantity, ingredientId, unitId };
  });
}

/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
export interface UnitConversionEntry {
  id: number;
  type: UnitType;
  toBaseFactor: number;
}

/**
 * Combines `a`/`b` — two lines already confirmed to resolve to the same
 * ingredient — into one, summing their quantities, or returns `null` when
 * that can't be done safely. `null` (quantity or unit missing on either
 * side, unit not found in `unitById`, mismatched `UnitType`, or either side
 * a `COUNT` unit) means "don't merge", not "error" — see
 * {@link mergeDuplicateIngredients}.
 *
 * Same unit on both sides sums directly. Different units of the same
 * measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
 * unit via `toBaseFactor` first — the groundwork that field's own doc
 * comment (`UnitView`, `packages/shared`) already anticipated ("a future
 * conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
 * never converted against each other even when `toBaseFactor` matches — a
 * "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two
 * different `COUNT` units for the same ingredient are left unmerged.
 * Rounded to 2 decimal places (`RecipeIngredient.quantity` is
 * `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
 * conversion.
 */
function combineIngredientLines(
  a: TranslatedRecipeIngredient,
  b: TranslatedRecipeIngredient,
  unitById: Map<number, UnitConversionEntry>,
): TranslatedRecipeIngredient | null {
  if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
    return null;
  }
  if (a.unitId === b.unitId) {
    return {
      ...a,
      quantity: a.quantity + b.quantity,
      rawText: `${a.rawText} + ${b.rawText}`,
    };
  }

  const unitA = unitById.get(a.unitId);
  const unitB = unitById.get(b.unitId);
  if (!unitA || !unitB) return null;
  if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;

  const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
  const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
  return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
}

/**
 * Folds `ingredients` down to one line per resolved `ingredientId`,
 * concatenating (summing the quantity of) every duplicate into the first
 * line it matches — see issue #53's follow-up: two raw source lines (e.g.
 * TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
 * steps) can independently resolve to the same catalog `Ingredient`, and
 * `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
 * allows one row per ingredient per recipe — the review form used to
 * either crash on submit (before `createRecipeSchema` rejected it) or
 * require the person to manually delete every extra line by hand.
 *
 * Unresolved lines (`ingredientId: null`) are never merged with one
 * another or with anything else — nothing reliable to key them on. Two
 * lines that resolve to the same ingredient but can't be combined safely
 * (see {@link combineIngredientLines} — mismatched quantity/unit, or
 * genuinely incompatible units) are left as separate, still-duplicate
 * lines: `createRecipeSchema` still rejects the result, and
 * `RecipeImportForm` still highlights them, same safety net as before this
 * merge step existed — merging never *invents* a number it isn't confident
 * in.
 *
 * Pure — testable with a hand-built `unitCatalog`, no database involved.
 * Order-preserving: a merged line keeps its first occurrence's position.
 */
export function mergeDuplicateIngredients(
  ingredients: TranslatedRecipeIngredient[],
  unitCatalog: UnitConversionEntry[],
): TranslatedRecipeIngredient[] {
  const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
  const merged: TranslatedRecipeIngredient[] = [];
  const mergedIndexByIngredientId = new Map<number, number>();

  for (const line of ingredients) {
    const existingIndex =
      line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;

    const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
    if (existingIndex === undefined || existingLine === undefined) {
      if (line.ingredientId !== null) {
        mergedIndexByIngredientId.set(line.ingredientId, merged.length);
      }
      merged.push(line);
      continue;
    }

    const combined = combineIngredientLines(existingLine, line, unitById);
    if (combined === null) {
      merged.push(line);
    } else {
      merged[existingIndex] = combined;
    }
  }

  return merged;
}

/**
 * Convenience wrapper around {@link translateRecipeSteps}/
 * {@link translateRecipeIngredients} that loads every catalog itself —
 * what a caller reaches for when translating a single recipe on its own
 * (e.g. the eventual "import this one recipe" endpoint). A caller
 * translating many recipes at once should load the catalogs once and reuse
 * them across calls instead, the same "don't requery per item" reasoning
 * `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
 * manually-authored recipes.
 *
 * No user- or recipe-level language preference exists anywhere in the app
 * yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) — callers
 * pass a locale explicitly rather than this module guessing one. Note that
 * an English-language source (e.g. TheMealDB) translated against `"fr"`
 * will currently get an empty (or nonsensical) `techStepIds` sequence on
 * every step — the classifier is trained per-locale, so calling it with a
 * locale that doesn't match the actual text's language doesn't degrade
 * gracefully, it just gets things wrong.
 *
 * Ingredient/unit matching has data for `"en"` and `"fr"` today
 * (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
 * — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
 * called, never specially skipped for a particular locale: a locale with no
 * label table of its own (anything but `"en"`/`"fr"`) just gets back empty
 * catalogs from those two loaders, and `translateRecipeIngredients` over an
 * empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
 * at the neutral `null` `translateRecipeSteps` already stubs in — the same
 * "no matching-language data" degradation tech-step matching already has
 * for a locale with no mappings, just arrived at by *not* special-casing
 * which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
 * `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` — this function
 * doesn't need its own copy of that list to stay in sync with).
 */
export async function translateRecipe(
  recipe: ParsedRecipe,
  locale: string,
): Promise<TranslatedRecipe> {
  try {
    const translated = await translateRecipeSteps(recipe, locale);

    const [ingredientCatalog, unitCatalog] = await Promise.all([
      loadIngredientCatalog(locale),
      loadUnitCatalog(locale),
    ]);
    return {
      ...translated,
      ingredients: translateRecipeIngredients(
        recipe.ingredients,
        ingredientCatalog,
        unitCatalog,
        locale,
      ),
    };
  } catch (err) {
    // Rethrown as-is — the caller (`sources.service.ts`) already
    // handles/logs failures centrally; this function just isn't allowed a
    // bare `await` per the repo's async/try-catch convention.
    throw err;
  }
}
