import { HttpError } from "@batch-cooking/error-tools";
import {
  ErrorCode,
  type StepTechStepCorrectionView,
  type SubmitTechStepCorrectionInput,
  type SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";

/**
 * User-submitted corrections to a step's detected techniques
 * (`StepTechStepCorrection` in schema.prisma) — kept in its own module
 * rather than folded into `recipe.service.ts`, same "one file per concern"
 * split that file itself follows for `tech-step-matcher.ts`. Deliberately
 * open to *any* viewer who can see the recipe, not just its author (unlike
 * every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
 * correcting a mislabeled technique isn't editing the recipe's own
 * content, and restricting it to authors would starve the training-data
 * feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
 */

type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
  include: { previousTechStep: true; correctedTechStep: true };
}>;

const correctionInclude = {
  previousTechStep: true,
  correctedTechStep: true,
} satisfies Prisma.StepTechStepCorrectionInclude;

/**
 * Loads `stepId`'s current `description` length (the only thing a
 * correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
 * if no such step exists, or if it exists but doesn't belong to `recipeId`
 * (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
 * request naming a real step under the wrong recipe should look identical
 * to naming one that doesn't exist, same "don't leak which part was wrong"
 * posture `assertRecipeVisible` already has for visibility). Otherwise
 * whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
 * never `403`) if the recipe exists but isn't visible to the viewer.
 */
async function loadVisibleStepOrThrow(
  recipeId: number,
  stepId: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> {
  try {
    const step = await prisma.step.findUnique({
      where: { id: stepId },
      select: { id: true, recipeId: true, description: true },
    });
    if (!step || step.recipeId !== recipeId) {
      throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
    }
    await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
    return { id: step.id, descriptionLength: step.description.length };
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  }
}

/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
async function assertTechStepsExist(ids: number[]): Promise<void> {
  try {
    if (ids.length === 0) return;
    const found = await prisma.techStep.findMany({
      where: { id: { in: ids } },
      select: { id: true },
    });
    const foundIds = new Set(found.map((techStep) => techStep.id));
    const missing = ids.filter((id) => !foundIds.has(id));
    if (missing.length > 0) {
      throw new HttpError(
        404,
        ErrorCode.TECH_STEP_NOT_FOUND,
        `TechStep ids not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}

/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
async function assertIngredientsExist(ids: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(ids)];
    if (uniqueIds.length === 0) return;
    const found = await prisma.ingredient.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    const foundIds = new Set(found.map((ingredient) => ingredient.id));
    const missing = uniqueIds.filter((id) => !foundIds.has(id));
    if (missing.length > 0) {
      throw new HttpError(
        404,
        ErrorCode.INGREDIENT_NOT_FOUND,
        `Ingredient ids not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}

/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
async function assertUnitsExist(ids: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(ids)];
    if (uniqueIds.length === 0) return;
    const found = await prisma.unit.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    const foundIds = new Set(found.map((unit) => unit.id));
    const missing = uniqueIds.filter((id) => !foundIds.has(id));
    if (missing.length > 0) {
      throw new HttpError(
        404,
        ErrorCode.UNIT_NOT_FOUND,
        `Unit ids not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}

/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
async function assertUtensilsExist(ids: number[]): Promise<void> {
  try {
    const uniqueIds = [...new Set(ids)];
    if (uniqueIds.length === 0) return;
    const found = await prisma.utensil.findMany({
      where: { id: { in: uniqueIds } },
      select: { id: true },
    });
    const foundIds = new Set(found.map((utensil) => utensil.id));
    const missing = uniqueIds.filter((id) => !foundIds.has(id));
    if (missing.length > 0) {
      throw new HttpError(
        404,
        ErrorCode.UTENSIL_NOT_FOUND,
        `Utensil ids not found: ${missing.join(", ")}`,
      );
    }
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}

/**
 * Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
 * ascending `start` (nulls-still-possible legacy rows, see that model's
 * schema doc comment, sort last) — the dense, reading-order 0-based
 * sequence `@@id([stepId, order])` requires, regardless of whether a
 * caller just inserted, updated, or deleted a row. Simpler and less
 * error-prone than shifting only the affected neighbors' `order` by hand.
 *
 * Two passes, through a disjoint negative range first: updating straight
 * into the final 0..N-1 positions in one pass risks a transient
 * `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
 * necessarily the one already sitting there) — `order` is always `>= 0`
 * in real usage, so a negative range can never collide with a live row.
 *
 * Exported for `scripts/backfill-tech-steps.ts` to reuse after it
 * recomputes just the `"auto"` subset of a step's rows, so the combined
 * `"auto"` + `"manual"` sequence still ends up in one coherent
 * reading-order.
 */
export async function renumberStepTechSteps(
  tx: Prisma.TransactionClient,
  stepId: number,
): Promise<void> {
  const rows = await tx.stepTechStep.findMany({ where: { stepId } });
  const sorted = [...rows].sort(
    (a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
  );
  for (const [index, row] of sorted.entries()) {
    await tx.stepTechStep.update({
      where: { stepId_order: { stepId, order: row.order } },
      data: { order: -(index + 1) },
    });
  }
  for (const [index] of sorted.entries()) {
    await tx.stepTechStep.update({
      where: { stepId_order: { stepId, order: -(index + 1) } },
      data: { order: index },
    });
  }
}

/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
interface ManualIngredientMention {
  ingredientId: number;
  quantity: number | null;
  unitId: number | null;
  start: number;
  end: number;
}
interface ManualUtensilMention {
  utensilId: number;
  start: number;
  end: number;
}

/**
 * Applies a correction's *effect* on `stepId`'s real `StepTechStep`
 * sequence, immediately — not just recorded as a pending suggestion for
 * `services/tech-step-llm-worker` to eventually process (see
 * `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
 * that offline feedback loop, not instead of it). `previousTechStepId`/
 * `correctedTechStepId` mean exactly what they do on
 * `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
 * comment, `packages/shared`):
 *
 * - `correctedTechStepId` set (add or relabel): a `"manual"` row is
 *   written at the correction's own `[start, end)` — updating the
 *   existing entry in place when one matching `previousTechStepId`
 *   overlaps this span, otherwise inserting a new one. No `contextStart`/
 *   `contextEnd` — a correction only ever carries the tight span the user
 *   themselves selected/clicked, nothing wider to highlight around it.
 * - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
 *   matching existing entry is deleted outright (cascading away any
 *   ingredient/utensil metadata attached to it, auto or manual — nothing
 *   left to attach metadata to once the technique itself is gone). A
 *   no-op if none matches (nothing to remove).
 *
 * `metadata`, when given (only ever alongside a real `correctedTechStepId`
 * — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
 * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
 * this occurrence — `source: "auto"` (the classifier's own detection) and
 * any earlier `"manual"` set alike — with the newly-submitted one. This is
 * "le manuel remplace tout" (confirmed with the user): the resolved
 * `order` this technique ends up at (whichever branch above produced it)
 * is the same `techStepOrder` both metadata tables key on, so the same
 * `deleteMany` + `createMany` pair below is correct whether this call just
 * updated an existing row (which may already carry auto-detected
 * metadata) or created a brand new one (nothing to delete yet — a no-op
 * `deleteMany`, not a special case).
 *
 * Runs inside the same transaction {@link submitTechStepCorrection} uses
 * for the audit-trail insert, so a request never leaves any of these
 * effects (the permanent correction record, the live sequence change, the
 * metadata replacement) only partially applied.
 */
async function applyManualCorrection(
  tx: Prisma.TransactionClient,
  stepId: number,
  span: { start: number; end: number },
  previousTechStepId: number | null,
  correctedTechStepId: number | null,
  metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
): Promise<void> {
  const existing = await tx.stepTechStep.findMany({ where: { stepId } });

  const target =
    previousTechStepId !== null
      ? existing.find(
          (row) =>
            row.techStepId === previousTechStepId &&
            row.start !== null &&
            row.end !== null &&
            row.start < span.end &&
            span.start < row.end,
        )
      : undefined;

  if (correctedTechStepId !== null) {
    const order = target
      ? target.order
      : existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
    if (target) {
      await tx.stepTechStep.update({
        where: { stepId_order: { stepId, order } },
        data: {
          techStepId: correctedTechStepId,
          start: span.start,
          end: span.end,
          contextStart: null,
          contextEnd: null,
          source: "manual",
        },
      });
    } else {
      await tx.stepTechStep.create({
        data: {
          stepId,
          techStepId: correctedTechStepId,
          order,
          start: span.start,
          end: span.end,
          source: "manual",
        },
      });
    }

    if (metadata !== undefined) {
      await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
      await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
      if (metadata.ingredients.length > 0) {
        await tx.stepTechStepIngredient.createMany({
          data: metadata.ingredients.map((ingredient) => ({
            stepId,
            techStepOrder: order,
            ingredientId: ingredient.ingredientId,
            quantity: ingredient.quantity,
            unitId: ingredient.unitId,
            start: ingredient.start,
            end: ingredient.end,
            source: "manual",
          })),
        });
      }
      if (metadata.utensils.length > 0) {
        await tx.stepTechStepUtensil.createMany({
          data: metadata.utensils.map((utensil) => ({
            stepId,
            techStepOrder: order,
            utensilId: utensil.utensilId,
            start: utensil.start,
            end: utensil.end,
            source: "manual",
          })),
        });
      }
    }
  } else if (target) {
    await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
  }

  await renumberStepTechSteps(tx, stepId);
}

function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
  return {
    id: correction.id,
    start: correction.start,
    end: correction.end,
    previousTechStep: correction.previousTechStep
      ? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
      : null,
    correctedTechStep: correction.correctedTechStep
      ? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
      : null,
    createdAt: correction.createdAt.toISOString(),
  };
}

/**
 * Records one correction to `stepId`'s detected techniques, submitted by
 * `correctorId`, and immediately applies its effect to the step's real
 * `StepTechStep` sequence (a `"manual"`-tagged row — see
 * {@link applyManualCorrection}) — see
 * {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
 * for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
 * record itself is never edited/deleted afterward (see
 * `StepTechStepCorrection`'s schema doc comment) — only the live sequence
 * changes on a later correction to the same span.
 *
 * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
 *   {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
 *   `start`/`end` (the correction's own span, or any of
 *   `input.ingredients`/`input.utensils`' own spans) fall outside the
 *   step's current `description` (it may have been edited since the user
 *   last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
 *   `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
 *   doesn't exist.
 */
export async function submitTechStepCorrection(
  recipeId: number,
  stepId: number,
  input: SubmitTechStepCorrectionInput,
  correctorId: number,
  viewerHouseId: number | null,
): Promise<SubmitTechStepCorrectionResult> {
  try {
    const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);

    const spans = [
      { start: input.start, end: input.end },
      ...(input.ingredients ?? []),
      ...(input.utensils ?? []),
    ];
    for (const span of spans) {
      if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
        throw new HttpError(
          400,
          ErrorCode.INVALID_CORRECTION_SPAN,
          `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
        );
      }
    }

    const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
      (id): id is number => id !== null && id !== undefined,
    );
    await assertTechStepsExist(techStepIds);
    await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
    await assertUnitsExist(
      (input.ingredients ?? []).flatMap((i) =>
        i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
      ),
    );
    await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));

    const { correction, techSteps } = await prisma.$transaction(async (tx) => {
      const createdCorrection = await tx.stepTechStepCorrection.create({
        data: {
          stepId: step.id,
          correctorId,
          start: input.start,
          end: input.end,
          previousTechStepId: input.previousTechStepId ?? null,
          correctedTechStepId: input.correctedTechStepId ?? null,
        },
        include: correctionInclude,
      });

      await applyManualCorrection(
        tx,
        step.id,
        { start: input.start, end: input.end },
        input.previousTechStepId ?? null,
        input.correctedTechStepId ?? null,
        input.ingredients === undefined && input.utensils === undefined
          ? undefined
          : {
              ingredients: (input.ingredients ?? []).map((ingredient) => ({
                ingredientId: ingredient.ingredientId,
                quantity: ingredient.quantity ?? null,
                unitId: ingredient.unitId ?? null,
                start: ingredient.start,
                end: ingredient.end,
              })),
              utensils: (input.utensils ?? []).map((utensil) => ({
                utensilId: utensil.utensilId,
                start: utensil.start,
                end: utensil.end,
              })),
            },
      );

      // Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
      // `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
      // so the fresh sequence read right after a manual correction resolves
      // exactly the same way a normal `GET /recipes/:id` would.
      const freshTechSteps = await tx.stepTechStep.findMany({
        where: { stepId: step.id },
        orderBy: { order: "asc" },
        include: {
          techStep: true,
          ingredients: {
            include: {
              ingredient: {
                include: {
                  allergies: { include: { allergy: { include: { category: true } } } },
                  diets: { include: { diet: true } },
                },
              },
              unit: true,
            },
          },
          utensils: { include: { utensil: true } },
        },
      });

      return { correction: createdCorrection, techSteps: freshTechSteps };
    });

    analytics.recordEvent("tech_step.correction_submitted", {
      actorId: correctorId,
      context: {
        recipeId,
        stepId,
        previousTechStepId: input.previousTechStepId ?? null,
        correctedTechStepId: input.correctedTechStepId ?? null,
      },
    });

    return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}

/**
 * Every correction submitted so far for `stepId`, most recent first —
 * mainly useful for a user checking what's already been submitted (by
 * anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
 * doc comment, `packages/shared`).
 *
 * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
 */
export async function listTechStepCorrections(
  recipeId: number,
  stepId: number,
  viewerId: number,
  viewerHouseId: number | null,
): Promise<StepTechStepCorrectionView[]> {
  try {
    const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
    const corrections = await prisma.stepTechStepCorrection.findMany({
      where: { stepId: step.id },
      orderBy: { createdAt: "desc" },
      include: correctionInclude,
    });
    return corrections.map(toCorrectionView);
  } catch (err) {
    throw err; // see loadVisibleStepOrThrow's catch comment
  }
}
