import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
  normalizeText,
  splitIntoClauses,
  type TechniqueCandidate,
  techStepClassifier,
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";

describe("tech-step-matcher", () => {
  describe("normalizeText", () => {
    it("lowercases and strips accents", () => {
      expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
    });

    it("strips a variety of diacritics, including cedilla", () => {
      expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
    });

    it("leaves already-plain text unchanged, aside from casing", () => {
      expect(normalizeText("Mix everything")).to.equal("mix everything");
    });

    it("returns an empty string for an empty input", () => {
      expect(normalizeText("")).to.equal("");
    });
  });

  describe("splitIntoClauses", () => {
    // A candidate's own `uid` doesn't matter to the splitting logic itself
    // (it's opaque, carried through as `anchor`) — kept short and
    // arbitrary across these fixtures.
    function candidate(uid: string, start: number, end: number): TechniqueCandidate {
      return { uid, start, end };
    }

    it("returns the whole description as one anchor-less clause when there are no candidates", () => {
      const text = "Servir immédiatement";
      const result = splitIntoClauses(text, []);
      expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]);
    });

    it("returns the whole description as one clause anchored on the single candidate", () => {
      const melt = candidate("melt", 6, 13);
      const text = "Faire fondre le beurre";
      const result = splitIntoClauses(text, [melt]);
      expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
    });

    it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => {
      // "Préchauffer la poêle, puis faire fondre le beurre"
      //  0        1         2         3         4
      //  0123456789012345678901234567890123456789012345678901
      const preheat = candidate("preheat", 0, 11); // "Préchauffer"
      const melt = candidate("melt", 27, 39); // "faire fondre"
      const text = "Préchauffer la poêle, puis faire fondre le beurre";

      const result = splitIntoClauses(text, [preheat, melt]);

      expect(result).to.have.length(2);
      // The gap between the two candidates is [11, 27) — its raw midpoint
      // (19) falls inside "poêle" (see findGapSplitPoint's doc comment for
      // why that's specifically what this snaps away from); the nearest
      // actual whitespace to that midpoint is the space at 21, right after
      // the comma.
      expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat });
      expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt });
      // The two clauses are contiguous and cover the whole text.
      expect(
        text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end),
      ).to.equal(text);
    });

    it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => {
      const preheat = candidate("preheat", 0, 11);
      const melt = candidate("melt", 27, 39);
      // Passed in reverse — the function must still produce clauses in
      // reading order, each anchored on the right candidate.
      const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [
        melt,
        preheat,
      ]);
      expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["preheat", "melt"]);
    });

    it("produces N contiguous clauses for N candidates, each anchored on its own", () => {
      const a = candidate("a", 0, 3);
      const b = candidate("b", 10, 13);
      const c = candidate("c", 20, 23);
      const text = "x".repeat(30);

      const result = splitIntoClauses(text, [a, b, c]);

      expect(result).to.have.length(3);
      expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["a", "b", "c"]);
      // Contiguous: each clause's end is the next one's start.
      expect(result[0].start).to.equal(0);
      expect(result[0].end).to.equal(result[1].start);
      expect(result[1].end).to.equal(result[2].start);
      expect(result[2].end).to.equal(text.length);
    });

    it("clamps the split point to the earlier candidate's own end when two candidates are adjacent/overlapping", () => {
      // Gap midpoint would fall *before* `a`'s own end here — must not
      // produce a clause that cuts into `a`'s own anchor span.
      const a = candidate("a", 0, 10);
      const b = candidate("b", 8, 15);

      const result = splitIntoClauses("x".repeat(20), [a, b]);

      expect(result[0].end).to.be.at.least(a.end);
      expect(result[1].start).to.equal(result[0].end);
    });
  });

  describe("techStepClassifier", () => {
    // `techStepClassifier` is the one shared singleton (see
    // tech-step-matcher.ts's own doc comment on why) — these tests
    // exercise it against the real training corpus
    // (`services/tech-step-intent-service`'s `training_data.py`) and the
    // real seeded `TechStep` catalog, rather than synthetic injectable
    // fixtures the old regex-based `matchTechStepSpans(description,
    // mappings)` allowed. Every call round-trips over HTTP to a real,
    // locally running `services/tech-step-intent-service` (see that
    // service's own README and `apps/api/.env.test`) — that service trains
    // itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
    // root hook doesn't wait on it, CI's own "wait for /health" step
    // already does), so calls here are just a normal HTTP round-trip,
    // comfortably inside this suite's default 10s timeout (.mocharc.json).
    let simmerId: number;
    let cookId: number;
    let bakeId: number;
    let preheatId: number;
    let meltId: number;
    let boilId: number;
    let chopId: number;
    // Real seeded catalog entries that also happen to be mentioned by
    // several fixtures below now that `matchTechStepSpans` also resolves
    // ingredient/utensil metadata — see `matchTechStepSpans`'s own describe
    // block for where each of these gets used.
    let panId: number;
    let butterId: number;
    let onionId: number;
    let walnutsId: number;

    beforeEach(async () => {
      await resetDatabase();
      const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
        await Promise.all([
          prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
          prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
          prisma.utensil.findFirstOrThrow({ where: { key: "pan" } }),
          prisma.ingredient.findFirstOrThrow({ where: { key: "butter" } }),
          prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }),
          // "Noix" (walnuts) — turns out to also be a real seeded ingredient
          // label, and "noix" is literally the French word for "a pat of
          // butter" ("une noix de beurre") used in one of the fixtures
          // below, so it's a genuine (if slightly comical) second match
          // alongside "beurre" in that clause, not a fixture bug.
          prisma.ingredient.findFirstOrThrow({ where: { key: "walnuts" } }),
        ]);
      simmerId = simmer.id;
      cookId = cook.id;
      bakeId = bake.id;
      preheatId = preheat.id;
      meltId = melt.id;
      boilId = boil.id;
      chopId = chop.id;
      panId = pan.id;
      butterId = butter.id;
      onionId = onion.id;
      walnutsId = walnuts.id;
    });

    after(async () => {
      await prisma.$disconnect();
    });

    describe("matchTechSteps", () => {
      it("matches an exact expression", async () => {
        expect(
          await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"),
        ).to.deep.equal([simmerId]);
      });

      it("is case- and accent-insensitive", async () => {
        expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([
          simmerId,
        ]);
      });

      it("returns an empty sequence when nothing matches", async () => {
        expect(
          await techStepClassifier.matchTechSteps("Ranger les couverts dans le tiroir", "fr"),
        ).to.deep.equal([]);
      });

      it("returns an empty sequence for an empty description", async () => {
        expect(await techStepClassifier.matchTechSteps("", "fr")).to.deep.equal([]);
      });

      it("returns an empty sequence for a locale nothing was trained on", async () => {
        expect(
          await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "de"),
        ).to.deep.equal([]);
      });

      it("detects several distinct techniques in one step, in reading order", async () => {
        expect(
          await techStepClassifier.matchTechSteps(
            "Préchauffer la poêle, puis faire fondre le beurre",
            "fr",
          ),
        ).to.deep.equal([preheatId, meltId]);
      });

      it("reverses the sequence when the techniques are mentioned in the opposite order", async () => {
        expect(
          await techStepClassifier.matchTechSteps(
            "Faire fondre le beurre puis préchauffer le four",
            "fr",
          ),
        ).to.deep.equal([meltId, preheatId]);
      });

      it("still matches the generic technique on its own when the more specific one isn't implied", async () => {
        expect(
          await techStepClassifier.matchTechSteps("Faire cuire à feu moyen", "fr"),
        ).to.deep.equal([cookId]);
      });

      it("resolves the more specific technique when a generic one's own vocabulary is embedded in it", async () => {
        // "Cuire au four" literally contains "cuire" (the generic `cook`
        // verb) but means the more specific `bake` — the classifier (not
        // a weight table) is what has to get this right now.
        expect(
          await techStepClassifier.matchTechSteps("Cuire au four pendant 30 minutes", "fr"),
        ).to.deep.equal([bakeId]);
      });

      it("understands a technique described without ever naming it — the whole point of moving off pure keyword matching", async () => {
        // No literal "fondre"/"fondu" anywhere in this sentence, yet it
        // unambiguously means `melt` — this is the exact motivating case
        // (see this module's own doc comment) a regex could never catch.
        expect(
          await techStepClassifier.matchTechSteps(
            "jusqu'à ce que le beurre ait disparu dans la poêle",
            "fr",
          ),
        ).to.deep.equal([meltId]);
      });

      it("understands preheating described without the verb 'préchauffer'", async () => {
        expect(
          await techStepClassifier.matchTechSteps("mettre la poêle sur feu vif", "fr"),
        ).to.deep.equal([preheatId]);
      });

      it("matches English text against the English-trained vocabulary", async () => {
        expect(
          await techStepClassifier.matchTechSteps(
            "Bring a large saucepan of salted water to the boil",
            "en",
          ),
        ).to.deep.equal([boilId]);
      });
    });

    describe("matchTechStepSpans", () => {
      it("returns a tight keyword span, and a wider context span that's the whole description when there's only one candidate", async () => {
        const text = "Faire mijoter à feu doux";
        const result = await techStepClassifier.matchTechStepSpans(text, "fr");
        expect(result).to.deep.equal([
          {
            techStepId: simmerId,
            start: 6,
            end: 13,
            contextStart: 0,
            contextEnd: text.length,
            ingredients: [],
            utensils: [],
          },
        ]);
        expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
      });

      it("returns an empty list when nothing matches", async () => {
        expect(
          await techStepClassifier.matchTechStepSpans("Ranger les couverts dans le tiroir", "fr"),
        ).to.deep.equal([]);
      });

      it("returns each distinct technique's own tight keyword span and its own wider context span, in reading order", async () => {
        const text = "Préchauffer la poêle, puis faire fondre le beurre";
        const result = await techStepClassifier.matchTechStepSpans(text, "fr");

        expect(result).to.have.length(2);
        expect(result[0].techStepId).to.equal(preheatId);
        expect(result[1].techStepId).to.equal(meltId);
        // Each keyword span, sliced back out of the original text, is
        // exactly the word(s) that anchored that match — what the frontend
        // needs to highlight the exact right characters.
        expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
        expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
        // Each context span is the wider clause the keyword was found in —
        // the two are contiguous and cover the whole description between
        // them (see splitIntoClauses, which computed these).
        expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
          "Préchauffer la poêle,",
        );
        expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
          " puis faire fondre le beurre",
        );
        expect(result[0].contextEnd).to.equal(result[1].contextStart);
      });

      it("understands both techniques in the classic 'Dans une poêle chaude, faire chauffer une noix de beurre' example, each with its own keyword and context", async () => {
        // The motivating example for context spans in the first place:
        // `preheat`'s keyword is a noun phrase ("poêle chaude"), not a
        // verb — its context ("Dans une poêle chaude") is what actually
        // shows this is about preparing the pan, not (say) deglazing one.
        const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
        const result = await techStepClassifier.matchTechStepSpans(text, "fr");

        expect(result).to.have.length(2);
        expect(result[0]).to.deep.equal({
          techStepId: preheatId,
          start: 9,
          end: 21,
          contextStart: 0,
          contextEnd: 22,
          // "poêle" (the pan) sits inside this very clause — a separate
          // utensil mention from `preheat`'s own "poêle chaude" keyword
          // span above, found by the intent service's *other* PhraseMatcher
          // (see `IntentServiceEntity.kind`).
          ingredients: [],
          utensils: [{ utensilId: panId, start: 9, end: 14 }],
        });
        expect(result[1]).to.deep.equal({
          techStepId: meltId,
          start: 23,
          end: 37,
          contextStart: 22,
          contextEnd: text.length,
          // Two mentions in this clause: "noix" (walnuts — also a real
          // seeded ingredient, and literally the French word this phrase
          // uses for "a pat of [butter]") *and* "beurre" itself, in
          // reading order.
          ingredients: [
            { ingredientId: walnutsId, start: 42, end: 46, quantity: null, unitId: null },
            { ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
          ],
          utensils: [],
        });
        expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
        expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
          "Dans une poêle chaude,",
        );
        expect(text.slice(result[1].start, result[1].end)).to.equal("faire chauffer");
        expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
          " faire chauffer une noix de beurre",
        );
      });

      it("falls back to highlighting the whole clause for both spans when a technique was found with no literal anchor word", async () => {
        const text = "jusqu'à ce que le beurre ait disparu dans la poêle";
        const result = await techStepClassifier.matchTechStepSpans(text, "fr");
        expect(result).to.deep.equal([
          {
            techStepId: meltId,
            start: 0,
            end: text.length,
            contextStart: 0,
            contextEnd: text.length,
            // "beurre" and "poêle" are both mentioned in this same
            // anchor-less clause (there's no literal `melt` keyword here at
            // all — the whole point of this test, see its own title) —
            // still resolved, since ingredient/utensil scanning doesn't
            // depend on the clause having a technique anchor of its own.
            ingredients: [
              { ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
            ],
            utensils: [{ utensilId: panId, start: 45, end: 50 }],
          },
        ]);
      });

      it("chop matches English text against the English-trained vocabulary, tight keyword span", async () => {
        const text = "Chop the onions finely";
        const result = await techStepClassifier.matchTechStepSpans(text, "en");
        expect(result).to.deep.equal([
          {
            techStepId: chopId,
            start: 0,
            end: 4,
            contextStart: 0,
            contextEnd: text.length,
            ingredients: [
              { ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
            ],
            utensils: [],
          },
        ]);
        expect(text.slice(0, 4)).to.equal("Chop");
      });

      // Quantity+unit extraction itself (the leading-number-before-a-mention
      // heuristic) is covered in full, deterministically, by
      // `findIngredientMentions`'s own tests (`ingredient-matcher.test.ts`)
      // — deliberately not re-exercised here through a brand-new invented
      // sentence: a novel combination of words the real `textcat` (trained
      // on a fixed, finite corpus, see `training_data.py`) has never seen
      // together can land on a confidently-wrong technique for reasons
      // that have nothing to do with this file's own logic, making such a
      // test flaky against corpus/threshold changes rather than a
      // trustworthy regression guard. The two tests above/below already
      // demonstrate technique+ingredient+utensil co-occurring in one
      // clause using sentences already proven reliable by this suite.
    });
  });
});
