import { HttpError } from "@batch-cooking/error-tools";
import {
  type CorrectionAdminView,
  ErrorCode,
  type ListCorrectionsQuery,
  type ListSuggestionsQuery,
  type RetrainRequestInput,
  type RetrainResultView,
  type TrainingDataSnippetQuery,
  type TrainingDataSnippetView,
  type TrainingSuggestionAdminView,
  type TrainingSuggestionGroupView,
  type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
  MIN_OVERALL_F1,
  runTechStepEvalSuite,
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";

/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
const CORRECTIONS_PAGE_SIZE = 200;

const suggestionInclude = {
  techStep: { select: { key: true } },
  sourceCorrection: {
    include: {
      step: { select: { id: true, recipeId: true, description: true } },
      previousTechStep: { select: { key: true } },
      correctedTechStep: { select: { key: true } },
    },
  },
} as const;

type SuggestionRow = Awaited<
  ReturnType<
    typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
  >
>;

/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
  const correction = row.sourceCorrection;
  return {
    id: row.id,
    techStepKey: row.techStep.key,
    locale: row.locale,
    suggestedSynonyms: row.suggestedSynonyms,
    suggestedUtterances: row.suggestedUtterances,
    sourceType: row.sourceType,
    status: row.status,
    createdAt: row.createdAt.toISOString(),
    sourceCorrection: correction
      ? {
          id: correction.id,
          recipeId: correction.step.recipeId,
          stepId: correction.step.id,
          clauseText: correction.step.description.slice(correction.start, correction.end),
          previousTechStepKey: correction.previousTechStep?.key ?? null,
          correctedTechStepKey: correction.correctedTechStep?.key ?? null,
        }
      : null,
  };
}

/**
 * Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
 * grouped by technique key — same "one block per technique" organisation
 * as `list-pending-training-suggestions.ts`'s CLI report, which this UI
 * replaces.
 */
export async function listSuggestions(
  query: ListSuggestionsQuery,
): Promise<TrainingSuggestionGroupView[]> {
  try {
    const rows = await prisma.techStepTrainingSuggestion.findMany({
      where: {
        ...(query.status ? { status: query.status } : {}),
        ...(query.sourceType ? { sourceType: query.sourceType } : {}),
        ...(query.locale ? { locale: query.locale } : {}),
        ...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
      },
      orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
      include: suggestionInclude,
    });

    const byKey = new Map<string, TrainingSuggestionAdminView[]>();
    for (const row of rows) {
      const view = toSuggestionView(row);
      const group = byKey.get(view.techStepKey);
      if (group) group.push(view);
      else byKey.set(view.techStepKey, [view]);
    }
    return [...byKey.entries()]
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  }
}

/**
 * Raw `StepTechStepCorrection`s for the admin browser — newest first,
 * capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
 * `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
 * removals ("no technique here") that never become suggestions and are
 * otherwise invisible.
 */
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
  try {
    const consumedFilter =
      query.consumed === "true"
        ? { consumedAt: { not: null } }
        : query.consumed === "false"
          ? { consumedAt: null }
          : {};
    const correctedFilter =
      query.hasCorrectedTechStep === "true"
        ? { correctedTechStepId: { not: null } }
        : query.hasCorrectedTechStep === "false"
          ? { correctedTechStepId: null }
          : {};

    const rows = await prisma.stepTechStepCorrection.findMany({
      where: { ...consumedFilter, ...correctedFilter },
      orderBy: { createdAt: "desc" },
      take: CORRECTIONS_PAGE_SIZE,
      include: {
        step: { select: { id: true, recipeId: true, description: true } },
        previousTechStep: { select: { key: true } },
        correctedTechStep: { select: { key: true } },
      },
    });

    return rows.map((row) => ({
      id: row.id,
      recipeId: row.step.recipeId,
      stepId: row.step.id,
      stepDescription: row.step.description,
      clauseText: row.step.description.slice(row.start, row.end),
      start: row.start,
      end: row.end,
      previousTechStepKey: row.previousTechStep?.key ?? null,
      correctedTechStepKey: row.correctedTechStep?.key ?? null,
      createdAt: row.createdAt.toISOString(),
      consumedAt: row.consumedAt?.toISOString() ?? null,
    }));
  } catch (err) {
    throw err;
  }
}

/**
 * Curates one suggestion — edit its proposed synonyms/utterances and/or
 * flip its `status`. At least one field must be present.
 *
 * @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
 * @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
 */
export async function updateSuggestion(
  id: number,
  input: UpdateTrainingSuggestionInput,
): Promise<TrainingSuggestionAdminView> {
  try {
    if (
      input.status === undefined &&
      input.suggestedSynonyms === undefined &&
      input.suggestedUtterances === undefined
    ) {
      throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
    }

    const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
    if (!existing) {
      throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
    }

    const updated = await prisma.techStepTrainingSuggestion.update({
      where: { id },
      data: {
        ...(input.status !== undefined ? { status: input.status } : {}),
        ...(input.suggestedSynonyms !== undefined
          ? { suggestedSynonyms: input.suggestedSynonyms }
          : {}),
        ...(input.suggestedUtterances !== undefined
          ? { suggestedUtterances: input.suggestedUtterances }
          : {}),
      },
      include: suggestionInclude,
    });
    return toSuggestionView(updated);
  } catch (err) {
    throw err;
  }
}

/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
function dedupe(values: string[]): string[] {
  return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}

/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
function pythonListBody(entries: string[]): string {
  return entries.map((entry) => `    ${JSON.stringify(entry)},`).join("\n");
}

/**
 * Aggregates the synonyms/utterances of every suggestion matching
 * `techStepKey` + `locale` + `status` into a ready-to-paste
 * `training_data.py` block. **Read-only** — editing that Python file and
 * restarting the intent-service stay a manual maintainer step.
 */
export async function getTrainingDataSnippet(
  query: TrainingDataSnippetQuery,
): Promise<TrainingDataSnippetView> {
  try {
    const rows = await prisma.techStepTrainingSuggestion.findMany({
      where: {
        locale: query.locale,
        status: query.status,
        techStep: { key: query.techStepKey },
      },
      select: { suggestedSynonyms: true, suggestedUtterances: true },
    });

    const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
    const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));

    const snippet = [
      `# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
      `"synonyms": [`,
      pythonListBody(synonyms),
      `],`,
      `"utterances": [`,
      pythonListBody(utterances),
      `],`,
    ]
      .filter((line) => line.length > 0)
      .join("\n");

    return {
      techStepKey: query.techStepKey,
      locale: query.locale,
      status: query.status,
      suggestionCount: rows.length,
      synonyms,
      utterances,
      snippet,
    };
  } catch (err) {
    throw err;
  }
}

/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
let retrainInProgress = false;

/**
 * Runs the training-corpus regression gate then, if it passes, backfills
 * every step and marks the given suggestion ids — the same three steps as
 * `scripts/retrain-tech-steps.ts`, callable from the admin UI.
 *
 * **Only meaningful after** a maintainer has hand-edited
 * `services/tech-step-intent-service/intent_service/training_data.py` **and
 * restarted that service** (it trains once at boot) — this endpoint can do
 * neither, and the admin UI states that prominently.
 *
 * A failed gate returns `gatePassed: false` with no backfill / no marking
 * (HTTP 200 — it's an expected outcome to show the operator, not an error).
 *
 * @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
 */
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
  if (retrainInProgress) {
    throw new HttpError(
      409,
      ErrorCode.RETRAIN_ALREADY_RUNNING,
      "A retrain (F1 gate + backfill) is already running",
    );
  }
  retrainInProgress = true;
  try {
    const { overall } = await runTechStepEvalSuite();
    const gatePassed = overall.f1 >= MIN_OVERALL_F1;

    let backfilled: RetrainResultView["backfilled"] = null;
    const marked = { applied: 0, rejected: 0 };

    if (gatePassed) {
      backfilled = await backfillTechSteps();

      const appliedIds = input.appliedIds ?? [];
      const rejectedIds = input.rejectedIds ?? [];
      if (appliedIds.length > 0) {
        const { count } = await prisma.techStepTrainingSuggestion.updateMany({
          where: { id: { in: appliedIds } },
          data: { status: "applied" },
        });
        marked.applied = count;
      }
      if (rejectedIds.length > 0) {
        const { count } = await prisma.techStepTrainingSuggestion.updateMany({
          where: { id: { in: rejectedIds } },
          data: { status: "rejected" },
        });
        marked.rejected = count;
      }
    }

    return {
      f1: overall.f1,
      precision: overall.precision,
      recall: overall.recall,
      minF1: MIN_OVERALL_F1,
      gatePassed,
      backfilled,
      marked,
    };
  } catch (err) {
    throw err; // see recipe.service.ts's equivalent catch comment
  } finally {
    retrainInProgress = false;
  }
}
