import type {
  CatalogPlaceholderGroupView,
  ListPlaceholdersQuery,
  MarkPlaceholdersReviewedInput,
  PruneOrphansResultView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";

/**
 * Grouping key for two placeholder spellings that mean the same missing
 * ingredient — lower-cased, accent-stripped, punctuation-neutralised,
 * whitespace-collapsed. "Piment d'Espelette", "piment d espelette" and
 * "PIMENT  D'ESPELETTE" all normalise to `"piment d espelette"`, so the
 * admin view shows one gap, not three. Pure (no DB) — unit-tested on its
 * own, same split convention as `matchXxx()` vs `loadXxx()` elsewhere.
 */
export function normalizePlaceholderName(raw: string): string {
  return raw
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()
    .replace(/[^\p{Letter}\p{Number}]+/gu, " ")
    .trim()
    .replace(/\s+/g, " ");
}

/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
const placeholderInclude = {
  recipes: {
    take: 5,
    include: { recipe: { select: { id: true, name: true } } },
  },
} as const;

/**
 * Every placeholder `Ingredient` (see `Ingredient.isPlaceholder` in
 * schema.prisma), grouped by {@link normalizePlaceholderName} so a
 * maintainer reviews one row per *missing ingredient* rather than one per
 * recipe line. Ordered by recipe impact (most-requested gap first), then
 * name.
 *
 * `query.reviewed` selects which side to show: omitted / `"false"` drops
 * groups whose every row has already been triaged (`reviewedAt` set) — the
 * default working list; `"true"` keeps only those fully-triaged groups.
 */
export async function listPlaceholderGroups(
  query: ListPlaceholdersQuery,
): Promise<CatalogPlaceholderGroupView[]> {
  try {
    const rows = await prisma.ingredient.findMany({
      where: { isPlaceholder: true },
      include: placeholderInclude,
      orderBy: { createdAt: "asc" },
    });

    /** Accumulator per normalized name — mutated in the loop, shaped into the view after. */
    interface GroupAccumulator {
      normalizedName: string;
      displayNames: Set<string>;
      ingredientIds: number[];
      recipeIds: Set<number>;
      sampleRecipes: Map<number, string>;
      firstSeenAt: Date | null;
      allReviewed: boolean;
    }
    const groups = new Map<string, GroupAccumulator>();

    for (const row of rows) {
      const name = row.displayName ?? "";
      const normalizedName = normalizePlaceholderName(name);
      let group = groups.get(normalizedName);
      if (!group) {
        group = {
          normalizedName,
          displayNames: new Set(),
          ingredientIds: [],
          recipeIds: new Set(),
          sampleRecipes: new Map(),
          firstSeenAt: null,
          allReviewed: true,
        };
        groups.set(normalizedName, group);
      }
      if (name.length > 0) group.displayNames.add(name);
      group.ingredientIds.push(row.id);
      for (const link of row.recipes) {
        group.recipeIds.add(link.recipe.id);
        if (group.sampleRecipes.size < 5) group.sampleRecipes.set(link.recipe.id, link.recipe.name);
      }
      if (row.createdAt && (group.firstSeenAt === null || row.createdAt < group.firstSeenAt)) {
        group.firstSeenAt = row.createdAt;
      }
      if (row.reviewedAt === null) group.allReviewed = false;
    }

    // `reviewed=true` → the archive of handled gaps; anything else → the
    // working list of gaps still to look at.
    const wantReviewed = query.reviewed === "true";
    return [...groups.values()]
      .filter((group) => group.allReviewed === wantReviewed)
      .map((group) => ({
        normalizedName: group.normalizedName,
        displayNames: [...group.displayNames].sort((a, b) => a.localeCompare(b, "fr")),
        ingredientIds: group.ingredientIds,
        recipeCount: group.recipeIds.size,
        sampleRecipes: [...group.sampleRecipes.entries()].map(([id, name]) => ({ id, name })),
        firstSeenAt: group.firstSeenAt?.toISOString() ?? null,
        allReviewed: group.allReviewed,
      }))
      .sort(
        (a, b) => b.recipeCount - a.recipeCount || a.normalizedName.localeCompare(b.normalizedName),
      );
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  }
}

/**
 * Stamps `reviewedAt` on the given placeholder ids — a maintainer has seen
 * this gap (and, if it warranted it, added the real catalog entry by hand;
 * this endpoint never touches the catalog itself). Scoped to
 * `isPlaceholder: true` so a stray real id is a silent no-op, not a
 * mislabel. Returns how many rows were actually stamped.
 */
export async function markPlaceholdersReviewed(
  input: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
  try {
    const { count } = await prisma.ingredient.updateMany({
      where: { id: { in: input.ingredientIds }, isPlaceholder: true },
      data: { reviewedAt: new Date() },
    });
    return { reviewed: count };
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  }
}

/**
 * Deletes placeholder rows no recipe references any more — the debris left
 * when a recipe edit drops a placeholder line (the `RecipeIngredient` row
 * goes, the `Ingredient` row doesn't). A manual GC (button in the admin
 * catalog view, or `scripts/prune-orphan-placeholders.ts`) rather than a
 * cascade: a placeholder is still evidence of a catalog gap even with no
 * live recipe, so dropping it is a deliberate call, not automatic.
 */
export async function pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
  try {
    const { count } = await prisma.ingredient.deleteMany({
      where: { isPlaceholder: true, recipes: { none: {} } },
    });
    return { deleted: count };
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  }
}
