import { prisma } from "../../db/prisma.js";
import {
  findIngredientMentions,
  type IngredientMention,
  loadIngredientCatalog,
  loadUnitCatalog,
} from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js";

/**
 * Auto-detects which cooking techniques (`TechStep`) a free-text recipe
 * step description corresponds to — groundwork for a future batch-cooking
 * optimization algorithm, and (via `matchTechStepSpans`) what
 * `recipe.service.ts` persists as `StepTechStep.start`/`end` so the recipe
 * UI can highlight the exact matched words (see `StepView` in
 * `packages/shared`).
 *
 * Regex-only matching used to live here (matching literal verb-form
 * patterns from a DB-backed `TechStepMapping` table) but couldn't
 * generalize past its own vocabulary — a step describing melting butter as
 * "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
 * regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
 * small hybrid pipeline (originally built on `node-nlp`, now entirely
 * delegated to `services/tech-step-intent-service` — a spaCy-based
 * microservice, see {@link IntentServiceClient} and that service's own
 * README):
 *
 * 1. **NER** (the intent service's `PhraseMatcher`, built from its own
 *    `training_data.py`'s `synonyms`) finds every *candidate* technique
 *    mention in the whole description, each with its exact character span —
 *    mechanically the same job the old regexes did, just as flat synonym
 *    lists instead of hand-written patterns. This step alone is *not* the
 *    final answer — see step 3.
 * 2. The description is cut into clauses around those candidate spans
 *    ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
 *    poêle chaude, faire chauffer une noix de beurre" is both `preheat`
 *    and `melt`) needs each judged on its own surrounding context, not the
 *    whole step lumped into one classification.
 * 3. **NLP intent classification** (the intent service's `textcat`, trained
 *    on its own `training_data.py`'s `utterances`) then classifies each
 *    clause on its own — this is what actually delivers "meaning, not
 *    keywords": the classifier was deliberately trained on paraphrases that
 *    never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
 *    disparu" for `melt`), so a clause reaching it gets labeled by what it
 *    was trained to recognize as *meaning* a technique, not by which
 *    literal word the NER step happened to anchor on. The NER-implied
 *    technique is kept only as a fallback for a clause the classifier
 *    isn't confident about (see `CONFIDENCE_THRESHOLD`) — a clearly
 *    keyword-anchored clause a small model merely isn't sure how to
 *    classify shouldn't be dropped outright.
 *
 * A single instruction can genuinely involve more than one technique — see
 * point 2 above — so `matchTechSteps`/`matchTechStepSpans` both return the
 * whole *ordered sequence* they find, not a single winner, matching
 * `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
 *
 * `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
 * access) so they stay unit-testable in isolation (see
 * `test/tech-step-matcher.test.ts`); this class only ever needs a
 * `TechStep.key -> id` lookup from the DB, memoized on the shared
 * {@link techStepClassifier} singleton rather than repeated per call — the
 * NLP model itself trains once, inside `services/tech-step-intent-service`'s
 * own startup, entirely independently of this class (see that service's
 * README — this repo no longer pushes any corpus to it over HTTP).
 */

/**
 * Lowercases and strips diacritics (NFD decomposition + removal of
 * combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
 * `ingredient-matcher.ts` for its own, unrelated free-text matching — kept
 * here and exported rather than duplicated, this module owned it first.
 */
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;

export function normalizeText(text: string): string {
  return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
}

/**
 * One technique {@link matchTechStepSpans} found, alongside exactly where in
 * `description` it matched — two nested spans, both `[start, end)` (same
 * convention as `String.prototype.slice`):
 *
 * - `start`/`end` — the tight *keyword* span (e.g. "préchauffer") that
 *   directly triggered the match, or (when no NER anchor exists at all —
 *   see {@link splitIntoClauses}'s zero-candidate case) the whole clause,
 *   same as `contextStart`/`contextEnd` below.
 * - `contextStart`/`contextEnd` — the wider *clause* the keyword was found
 *   in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle
 *   chaude") — what actually got fed to the classifier (see this file's
 *   doc comment, point 3), kept alongside the tight span so a caller can
 *   show *both*: the exact trigger word(s), and how much of the sentence
 *   is understood to be about that technique. Always contains `start`/`end`
 *   (`contextStart <= start`, `end <= contextEnd`).
 *
 * Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
 * (`recipe.service.ts`) so the recipe detail view can highlight both spans,
 * not just know a technique was mentioned somewhere.
 *
 * `ingredients`/`utensils` are the metadata found in this match's own
 * *clause* (see this file's doc comment, point 2) — an ingredient/utensil
 * mentioned in a different clause of the same description belongs to
 * *that* clause's own match, never this one, the same "judged on its own
 * surrounding context" rule the technique itself is judged by. Always `[]`
 * rather than omitted when nothing was found, so every caller can iterate
 * unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
 * rows (`recipe.service.ts`).
 */
export interface TechStepMatch {
  techStepId: number;
  start: number;
  end: number;
  contextStart: number;
  contextEnd: number;
  ingredients: IngredientMention[];
  utensils: UtensilMention[];
}

/**
 * A utensil mention found by the intent service's utensil `PhraseMatcher`
 * (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
 * `intent-service-client.ts`), resolved to a local `Utensil.id` and
 * attributed to whichever clause its span falls inside — same
 * `[start, end)` convention as every other span in this file.
 */
export interface UtensilMention {
  utensilId: number;
  start: number;
  end: number;
}

/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
export interface TechniqueCandidate {
  /** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
  uid: string;
  start: number;
  end: number;
}

/** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */
export interface TechStepClause {
  /** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */
  start: number;
  end: number;
  /** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
  anchor: TechniqueCandidate | null;
}

/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
const SENTENCE_END_PATTERN = /[.!?]/;

/**
 * Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
 * candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
 * nearest the gap's midpoint when one exists in the gap, otherwise any
 * whitespace nearest the midpoint, so a clause boundary (surfaced to users
 * as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
 * which always lands on a real word by construction) never slices through
 * the middle of a word — found while testing a context span that cut
 * "poêle" into "poêl"/"e" across two clauses.
 *
 * The sentence-boundary preference matters beyond cosmetics: a description
 * with two techniques in two different sentences ("Préchauffer le four à
 * 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
 * get a plain nearest-midpoint whitespace split, which for a long first
 * sentence lands *inside* the second one — handing the classifier a clause
 * like "...(thermostat 6). Dans un saladier, mettre" that trails off
 * mid-instruction with no object. That garbled, incomplete text is nothing
 * like the short, complete training utterances, and was found to
 * misclassify real recipe steps with high (>0.65) confidence in both
 * halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause
 * as `melt`. Splitting at the real sentence boundary instead hands the
 * classifier two complete, grammatical clauses, each far closer to what it
 * was trained on.
 *
 * Falls back to the raw midpoint when the gap has no whitespace at all
 * (adjacent candidates, or a gap that's pure punctuation with no space) —
 * same "some split point, however imperfect" fallback a plain midpoint
 * always was.
 */
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
  if (gapStart >= gapEnd) return gapStart;
  const midpoint = Math.floor((gapStart + gapEnd) / 2);

  let bestSentenceEnd: number | null = null;
  let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
  let bestWhitespace: number | null = null;
  let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
  for (let i = gapStart; i < gapEnd; i++) {
    if (!/\s/.test(description[i] ?? "")) continue;
    const distance = Math.abs(i - midpoint);
    if (distance < bestWhitespaceDistance) {
      bestWhitespace = i;
      bestWhitespaceDistance = distance;
    }
    if (
      i > gapStart &&
      SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
      distance < bestSentenceEndDistance
    ) {
      bestSentenceEnd = i;
      bestSentenceEndDistance = distance;
    }
  }
  return bestSentenceEnd ?? bestWhitespace ?? midpoint;
}

/**
 * Cuts `description` into clauses around `candidates` (NER's found
 * technique mentions, already sorted or not — sorted internally), one
 * clause per candidate, so each can be judged by the classifier on its own
 * surrounding context rather than the whole (possibly multi-technique)
 * description at once.
 *
 * - **Zero candidates**: the whole description is one clause with no
 *   anchor — still worth classifying (a description mentioning no literal
 *   keyword at all can still *mean* a technique, the entire point of the
 *   classification step), just with no tight span to highlight, so callers
 *   fall back to highlighting the whole thing.
 * - **One candidate**: the whole description is one clause too (nothing to
 *   cut around a single mention), but *with* that candidate as its anchor
 *   — callers get its tight span for highlighting.
 * - **Two or more**: split points fall at the whitespace nearest the
 *   midpoint of each consecutive pair's `[end, nextStart]` gap (see
 *   {@link findGapSplitPoint} — never mid-word), producing that many
 *   contiguous, non-overlapping clauses covering the whole description —
 *   clause *i* is anchored on candidate *i*.
 *
 * Pure and DB/model-free — unit-tested directly (see
 * `test/tech-step-matcher.test.ts`) without needing a trained classifier.
 */
export function splitIntoClauses(
  description: string,
  candidates: TechniqueCandidate[],
): TechStepClause[] {
  if (candidates.length === 0) {
    return [{ start: 0, end: description.length, anchor: null }];
  }

  const sorted = [...candidates].sort((a, b) => a.start - b.start);
  const [first, ...rest] = sorted;
  if (first === undefined) {
    // Unreachable — `candidates.length === 0` already returned above, so
    // `sorted` (same length) always has a first element here. Satisfies
    // `noUncheckedIndexedAccess`, which can't see that from the length
    // check alone.
    return [{ start: 0, end: description.length, anchor: null }];
  }

  // Single pass, pairing each candidate with the next one as it goes —
  // avoids re-indexing a separately-built `splitPoints` array afterward
  // (also awkward under `noUncheckedIndexedAccess` for no real benefit,
  // since every split point is only ever read once, right after it's
  // computed).
  const clauses: TechStepClause[] = [];
  let clauseStart = 0;
  let anchor = first;
  for (const next of rest) {
    const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
    clauses.push({ start: clauseStart, end: splitPoint, anchor });
    clauseStart = splitPoint;
    anchor = next;
  }
  clauses.push({ start: clauseStart, end: description.length, anchor });
  return clauses;
}

/**
 * Below this confidence, a clause's classifier verdict isn't trusted on its
 * own — falls back to its NER anchor's own technique instead (see this
 * file's doc comment, point 3). Tuned empirically against
 * `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
 * cases this threshold was picked to pass.
 *
 * Recalibrated for the migration off `node-nlp` to
 * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
 * — its score distribution is meaningfully different from node-nlp's own
 * classifier, and shifts again every time the corpus' technique count
 * changes (more exclusive classes generally means a *lower* natural
 * confidence ceiling, softmax mass spread thinner).
 *
 * Currently `0.25`, set against the corpus as expanded to ~74 techniques
 * (`services/tech-step-intent-service/intent_service/training_data.py`,
 * `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
 * `synonyms` in addition to its `utterances` — see that constant's own
 * comment for the calibration history) from manual spot-checks, not yet a
 * real `calibrate-tech-step-threshold.ts` sweep against
 * `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc
 * comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
 * lowest, still correct in argmax and anchored anyway; `melt` highest, the
 * motivating anchor-less case), against a noise floor around `0.02`
 * (English text through the French classifier). `0.25` sits with real
 * margin above the noise floor and below every real case seen so far, but
 * **this is a placeholder pending the real eval-dataset sweep** — do not
 * treat it as load-bearing precision the way the original `0.45`
 * (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
 * F1 plateauing exactly there) was.
 */
export const CONFIDENCE_THRESHOLD = 0.25;

/**
 * One clause's full classification detail — the finer-grained sibling of
 * {@link TechStepMatch}, exposing the raw intent/score
 * `TechStepClassifierService`'s private `_classifyClause` normally
 * collapses into a single accepted-or-fallback verdict. Nothing on the
 * interactive save/read path needs this (that's exactly what
 * `_classifyClause`'s threshold + fallback logic is for) — it exists for
 * `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
 * (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
 * needs to see *which* clauses the classifier itself wasn't sure about, not
 * just its final best-effort verdict.
 */
export interface TechStepClauseClassification {
  /** The clause's own text (`description.slice(start, end)`, trimmed). */
  clauseText: string;
  start: number;
  end: number;
  /** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
  anchorUid: string | null;
  /** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
  intentUid: string | null;
  /** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
  score: number;
}

/**
 * Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
 * a real class (not a plain object of functions) per this repo's
 * service-style-logic convention, even though it's only ever used as the
 * one shared {@link techStepClassifier} singleton below: it holds real
 * state (the memoized lookup promise), not just grouped stateless helpers.
 * The actual NER/intent-classification model lives entirely in
 * `services/tech-step-intent-service` (a separate process, trained from
 * its own `training_data.py` at its own startup) — this class never
 * trains or pushes anything to it, it only calls `POST /v1/process` and
 * resolves whatever `uid` comes back to a local DB id.
 */
export class TechStepClassifierService {
  /** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
  private _techStepIdsLoaded: Promise<void> | undefined;
  private _techStepIdByUid: Map<string, number> | undefined;

  /** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
  private _utensilIdsLoaded: Promise<void> | undefined;
  private _utensilIdByUid: Map<string, number> | undefined;

  /**
   * Forces the `TechStep.key -> id` lookup to load now, synchronously with
   * server startup (see `server.ts`, which also retries this against a
   * not-yet-reachable intent service), rather than stalling whichever
   * request happens to be first to save/preview a recipe. Doesn't wait on
   * `services/tech-step-intent-service` finishing its own training — that
   * service is only ever considered "up" by Docker Compose/CI once it
   * already is (see that service's `GET /health`), so by the time this
   * runs in a real deployment it's already trained; a request racing an
   * intent service that's genuinely still starting just gets an empty
   * match list back (see `IntentServiceProcessResult`'s own doc comment),
   * not an error.
   */
  public async warmUp(): Promise<void> {
    try {
      await this.matchTechStepSpans("faire cuire à feu doux", "fr");
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }

  /**
   * Detects every technique `description` means, as an ordered sequence of
   * matches (each carrying *where* it matched) — empty if none apply. See
   * this file's doc comment for the full NER -> split -> classify
   * pipeline.
   *
   * @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
   *   against — same "caller already knows/validated this" contract the
   *   old `matchTechStepSpans(description, mappings)` had via its
   *   pre-filtered `mappings` argument, just as an explicit parameter now
   *   that the training data isn't pre-filtered by the caller anymore.
   */
  public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
    try {
      await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
      if (description.trim().length === 0) return [];

      // Loaded fresh per call (once per step, see `recipe.service.ts`'s
      // `matchStepsTechSteps`) rather than memoized like the id lookups
      // above — same "cheap enough, and reference data can change between
      // calls without a restart" posture `loadIngredientCatalog`/
      // `loadUnitCatalog`'s own doc comments already describe for their
      // other callers (`ingredient-matcher.ts`, `sources.service.ts`).
      const [ingredientCatalog, unitCatalog] = await Promise.all([
        loadIngredientCatalog(locale),
        loadUnitCatalog(locale),
      ]);

      // The intent service returns two kinds of candidate (see `kind` on
      // `IntentServiceEntity`): technique mentions (its corpus-trained
      // `PhraseMatcher`) and utensil mentions (its static one, see
      // `utensil_vocabulary.py`). Only the former ever anchor a clause —
      // `splitIntoClauses` cuts a description around *techniques*, a
      // mentioned utensil doesn't introduce a clause boundary of its own,
      // it just gets attributed to whichever clause its span falls inside
      // (see the loop below). Its `start`/`end` are already `[start, end)`
      // (matching `String.prototype.slice`), unlike node-nlp's inclusive
      // `end` — no `+ 1` needed either.
      const nerResult = await intentServiceClient.process(locale, description);
      const candidates: TechniqueCandidate[] = nerResult.entities
        .filter((entity) => entity.kind === "technique")
        .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
      const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");

      const clauses = splitIntoClauses(description, candidates);
      const matches: TechStepMatch[] = [];
      for (const clause of clauses) {
        const uid = await this._classifyClause(description, clause, locale);
        if (uid === null) continue;
        const techStepId = this._techStepIdByUid?.get(uid);
        // A `uid` the classifier/NER was trained on but that no longer has
        // a matching `TechStep` row (e.g. training data and
        // `reference-seed-data.ts` drifted apart) — skip rather than
        // persist a dangling id.
        if (techStepId === undefined) continue;
        const span = clause.anchor ?? { start: clause.start, end: clause.end };

        const ingredients = findIngredientMentions(
          description.slice(clause.start, clause.end),
          ingredientCatalog,
          unitCatalog,
          locale,
        ).map((mention) => ({
          ...mention,
          start: mention.start + clause.start,
          end: mention.end + clause.start,
        }));

        const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
          if (entity.start < clause.start || entity.end > clause.end) return [];
          const utensilId = this._utensilIdByUid?.get(entity.uid);
          // Same drift guard as `techStepId` above.
          return utensilId === undefined
            ? []
            : [{ utensilId, start: entity.start, end: entity.end }];
        });

        matches.push({
          techStepId,
          start: span.start,
          end: span.end,
          contextStart: clause.start,
          contextEnd: clause.end,
          ingredients,
          utensils,
        });
      }

      matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
      return matches;
    } 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` without a
      // try/catch per the repo's convention.
      throw err;
    }
  }

  /**
   * Splits `description` into clauses exactly like {@link matchTechStepSpans}
   * does, but returns each clause's *raw* classification detail
   * ({@link TechStepClauseClassification}) instead of the threshold-applied,
   * anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
   * for why/who needs this. Deliberately a separate traversal rather than a
   * shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
   * exists purely to add a new, additive read path without risking a
   * behavior change to the two already-relied-on methods above.
   */
  public async classifyClauses(
    description: string,
    locale: string,
  ): Promise<TechStepClauseClassification[]> {
    try {
      await this._ensureTechStepIdsLoaded();
      if (description.trim().length === 0) return [];

      const nerResult = await intentServiceClient.process(locale, description);
      const candidates: TechniqueCandidate[] = nerResult.entities
        .filter((entity) => entity.kind === "technique")
        .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));

      const clauses = splitIntoClauses(description, candidates);
      const results: TechStepClauseClassification[] = [];
      for (const clause of clauses) {
        const clauseText = description.slice(clause.start, clause.end).trim();
        const anchorUid = clause.anchor?.uid ?? null;
        if (clauseText.length === 0) {
          results.push({
            clauseText,
            start: clause.start,
            end: clause.end,
            anchorUid,
            intentUid: null,
            score: 0,
          });
          continue;
        }
        const result = await intentServiceClient.process(locale, clauseText);
        results.push({
          clauseText,
          start: clause.start,
          end: clause.end,
          anchorUid,
          intentUid: result.intent,
          score: result.intent === null ? 0 : result.score,
        });
      }
      return results;
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }

  /**
   * Convenience wrapper around {@link matchTechStepSpans} for callers that
   * only care about *which* techniques matched, not where — e.g.
   * `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
   * step's technique sequence for an imported recipe that isn't saved (and
   * so has no `StepTechStep` row to persist a span into) yet.
   */
  public async matchTechSteps(description: string, locale: string): Promise<number[]> {
    try {
      return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }

  /**
   * Classifies one clause, returning the technique `uid` it means (or
   * `null` if none applies) — the classifier's own verdict when it's
   * confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
   * clause's NER anchor (if it has one) as a floor: a clearly
   * keyword-anchored clause a small model merely isn't sure how to
   * classify shouldn't be dropped outright, only a genuinely
   * anchor-less/low-confidence one should.
   */
  private async _classifyClause(
    description: string,
    clause: TechStepClause,
    locale: string,
  ): Promise<string | null> {
    try {
      const clauseText = description.slice(clause.start, clause.end).trim();
      if (clauseText.length === 0) return clause.anchor?.uid ?? null;

      const result = await intentServiceClient.process(locale, clauseText);
      if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
        return result.intent;
      }
      return clause.anchor?.uid ?? null;
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }

  /**
   * Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
   * `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
   * the same recipe save, awaited via the same event loop tick) all await
   * the one in-flight DB query rather than each firing their own.
   */
  private async _ensureTechStepIdsLoaded(): Promise<void> {
    if (this._techStepIdsLoaded === undefined) {
      this._techStepIdsLoaded = this._loadTechStepIds();
    }
    try {
      await this._techStepIdsLoaded;
    } catch (err) {
      // A failed load must be retried by the *next* call, not leave every
      // future call permanently rejecting against a stale failed promise.
      this._techStepIdsLoaded = undefined;
      throw err;
    }
  }

  private async _loadTechStepIds(): Promise<void> {
    try {
      const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
      this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }

  /** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
  private async _ensureUtensilIdsLoaded(): Promise<void> {
    if (this._utensilIdsLoaded === undefined) {
      this._utensilIdsLoaded = this._loadUtensilIds();
    }
    try {
      await this._utensilIdsLoaded;
    } catch (err) {
      this._utensilIdsLoaded = undefined;
      throw err;
    }
  }

  private async _loadUtensilIds(): Promise<void> {
    try {
      const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
      this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
    } catch (err) {
      throw err; // see matchTechStepSpans()'s catch comment above
    }
  }
}

/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
export const techStepClassifier = new TechStepClassifierService();
