import { HttpError } from "@batch-cooking/error-tools";
import {
  type BrowsableSourceItemView,
  type CreateRecipeInput,
  type DraftRecipeIngredientView,
  type DraftRecipeStepView,
  ErrorCode,
  type RecipeImportDraftView,
  type RecipeView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
  loadIngredientCatalog,
  loadUnitCatalog,
} from "../../lib/recipe-matching/ingredient-matcher.js";
import {
  mergeDuplicateIngredients,
  translateRecipeIngredients,
} from "../../lib/recipe-matching/recipe-translation.js";
import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js";
import {
  markAlreadyImported,
  type RecipeSourceAdapter,
} from "../../lib/recipe-sources/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";

/**
 * Browsing, previewing, and importing a household's *enabled* external
 * recipe sources (`HouseSource`) — the "onglet Sources" feature (see the
 * project plan). Browsing lists what a source offers
 * (`RecipeSourceAdapter.list()`); previewing fully translates one item
 * (`translateRecipeIngredients`, `matchTechStepSpans` — same building
 * blocks `recipe.service.ts` uses at real save time) without persisting
 * it; importing (`importSourceItem`) is the only function here that
 * actually saves — by the time it's called, the caller (the review screen)
 * has already resolved every ingredient to a real catalog id, same as a
 * manual `POST /recipes`.
 */

/**
 * `sourceKey` must both exist as a `Source` (household-enabled, via
 * `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
 * — the two can drift apart (a `Source` row outlives its adapter being
 * unregistered, exactly what `jsonLdRecipe` was cleaned up from — see
 * `sources/index.ts`), so both are checked. Either failure looks like "this
 * source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
 * "don't distinguish not-found from not-visible" posture `recipe.service.ts`
 * takes for a recipe the viewer can't see.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
 */
async function assertSourceEnabled(
  houseId: number | null,
  sourceKey: string,
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
  try {
    const enabledSourceIds = await getHouseSourceIds(houseId);
    const source = await prisma.source.findUnique({
      where: { key: sourceKey },
    });
    if (!source || !enabledSourceIds.includes(source.id)) {
      throw new HttpError(
        404,
        ErrorCode.SOURCE_NOT_FOUND,
        `Source "${sourceKey}" is not enabled for this household`,
      );
    }
    const adapter = getRecipeSource(sourceKey);
    if (!adapter) {
      throw new HttpError(
        404,
        ErrorCode.SOURCE_NOT_FOUND,
        `Source "${sourceKey}" has no registered adapter`,
      );
    }
    return { adapter, sourceId: source.id };
  } catch (err) {
    // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
    // already logs it, see `error-logger.ts`) is what actually handles it,
    // this service layer just isn't allowed a bare `await` per the repo's
    // async/try-catch convention.
    throw err;
  }
}

/**
 * One page of `sourceKey`'s own catalog, each item flagged with whether
 * it's already been imported (and, if so, its real `Recipe` id — see
 * `findImportedRecipeIds`).
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
 */
export async function browseSource(
  sourceKey: string,
  houseId: number | null,
  params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
  try {
    const { adapter } = await assertSourceEnabled(houseId, sourceKey);
    const result = await adapter.list({
      query: params.query,
      cursor: params.cursor,
    });

    const importedRecipeIds = await findImportedRecipeIds(
      prisma,
      sourceKey,
      result.items.map((item) => item.externalId),
    );
    const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));

    return {
      items: marked.map((item) => ({
        externalId: item.externalId,
        title: item.title,
        picture: item.picture,
        url: item.url,
        alreadyImported: item.alreadyImported,
        recipeId: importedRecipeIds.get(item.externalId) ?? null,
      })),
      nextCursor: result.nextCursor,
    };
  } catch (err) {
    throw err; // see assertSourceEnabled()'s catch comment above
  }
}

/**
 * Fully translates one source item into an unsaved {@link RecipeImportDraftView}
 * — fetches + parses it (`fetchDetail`/`parse`), then resolves its
 * ingredients/units (`translateRecipeIngredients`) and detects each step's
 * techniques with their exact matched span (`matchTechStepSpans`, the same
 * function `recipe.service.ts` uses at real save time — see its doc
 * comment), all against `adapter.locale`'s catalogs. Ingredient/unit
 * matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
 * `loadIngredientCatalog`/`loadUnitCatalog` are always called with
 * `adapter.locale` directly, never specially skipped for a particular
 * one — a source whose locale has no label table of its own just gets back
 * empty catalogs from those two loaders, so every line's `ingredient`/
 * `unit` end up `null` the same way, the same graceful "no
 * matching-language data" degradation `translateRecipe` already has.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter).
 */
export async function previewSourceItem(
  sourceKey: string,
  externalId: string,
  houseId: number | null,
): Promise<RecipeImportDraftView> {
  try {
    const { adapter } = await assertSourceEnabled(houseId, sourceKey);

    let parsed: ReturnType<typeof adapter.parse>;
    try {
      const raw = await adapter.fetchDetail(externalId);
      parsed = adapter.parse(raw);
    } catch (err) {
      if (err instanceof RecipeSourceError) {
        throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
      }
      throw err;
    }

    const [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
      await Promise.all([
        Promise.all(
          parsed.steps.map(async (step) => ({
            step,
            matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
          })),
        ),
        loadIngredientCatalog(adapter.locale),
        loadUnitCatalog(adapter.locale),
        prisma.techStep.findMany({ select: { id: true, key: true } }),
      ]);
    const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));

    const translatedIngredients = translateRecipeIngredients(
      parsed.ingredients,
      ingredientCatalog,
      unitCatalog,
      adapter.locale,
    );
    const [ingredientViews, unitViews, utensilViews] = await Promise.all([
      getIngredients(),
      getUnits(),
      getUtensils(),
    ]);
    const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
    const unitById = new Map(unitViews.map((view) => [view.id, view]));
    const utensilById = new Map(utensilViews.map((view) => [view.id, view]));

    // A source's raw ingredient lines aren't deduplicated by the matcher —
    // two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
    // catalog ingredient. Folded into one line per ingredient (quantities
    // summed where that's safe) before the draft ever reaches the review
    // screen, rather than surfacing the recipe with two rows for "Œuf" and
    // making the person sort it out — see issue #53's follow-up.
    const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);

    const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
      rawText: ingredient.rawText,
      quantity: ingredient.quantity,
      ingredient:
        ingredient.ingredientId !== null
          ? (ingredientById.get(ingredient.ingredientId) ?? null)
          : null,
      unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
    }));

    const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
      description: step.description,
      picture: step.picture,
      techSteps: matches.flatMap((match) => {
        const techStep = techStepById.get(match.techStepId);
        return techStep
          ? [
              {
                techStep,
                start: match.start,
                end: match.end,
                contextStart: match.contextStart,
                contextEnd: match.contextEnd,
                // A draft preview has no persisted `StepTechStep` row to
                // read a real `source` from at all (it isn't a saved
                // recipe yet — see `DraftRecipeStepView`'s own doc
                // comment) — always the classifier's own live match,
                // never a correction, so always "auto".
                source: "auto",
                ingredients: match.ingredients.flatMap((mention) => {
                  const ingredient = ingredientById.get(mention.ingredientId);
                  // Same drift guard as `techStep` above — an ingredientId
                  // the matcher resolved but that's since vanished from the
                  // catalog is dropped rather than shown with a hole in it.
                  if (!ingredient) return [];
                  return [
                    {
                      ingredient,
                      quantity: mention.quantity,
                      unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
                      start: mention.start,
                      end: mention.end,
                      // Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
                      source: "auto" as const,
                    },
                  ];
                }),
                utensils: match.utensils.flatMap((mention) => {
                  const utensil = utensilById.get(mention.utensilId);
                  return utensil
                    ? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
                    : [];
                }),
              },
            ]
          : [];
      }),
    }));

    return {
      sourceKey,
      externalId,
      name: parsed.name,
      description: parsed.description,
      picture: parsed.picture,
      portions: parsed.portions,
      sourceUrl: parsed.sourceUrl,
      ingredients,
      steps,
    };
  } catch (err) {
    throw err; // see assertSourceEnabled()'s catch comment above
  }
}

/**
 * Finalizes an import — the review screen (pre-filled from
 * {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
 * the user via the normal `IngredientPicker`) submits `input` as a
 * regular {@link CreateRecipeInput}, exactly like a manually-authored
 * recipe. This just adds two things `createRecipe` itself can't:
 * confirming `externalId` isn't already imported (the DB's own
 * `@@unique([sourceId, externalId])` would reject a second attempt too,
 * but as a raw constraint violation — checking first gives a clean,
 * expected error instead), and stamping `sourceId`/`externalId` plus
 * matching techniques against the source's own locale
 * (`createImportedRecipe`, `recipe.service.ts`).
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
 * @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
 * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
 * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
 * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
 */
export async function importSourceItem(
  sourceKey: string,
  externalId: string,
  input: CreateRecipeInput,
  authorId: number,
  authorHouseId: number | null,
): Promise<RecipeView> {
  try {
    const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);

    const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
    if (alreadyImported.has(externalId)) {
      throw new HttpError(
        409,
        ErrorCode.RECIPE_ALREADY_IMPORTED,
        `"${externalId}" from source "${sourceKey}" is already imported`,
      );
    }

    return await createImportedRecipe(input, authorId, authorHouseId, {
      sourceId,
      externalId,
      locale: adapter.locale,
    });
  } catch (err) {
    throw err; // see assertSourceEnabled()'s catch comment above
  }
}
