import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";

async function seedAdmin(): Promise<{ email: string; password: string }> {
  const email = faker.internet.email().toLowerCase();
  const password = faker.internet.password({ length: 16 });
  await prisma.adminUser.create({
    data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
  });
  return { email, password };
}

async function techStepId(key: string): Promise<number> {
  return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}

/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
async function seedCorrectionAndSuggestion(options: {
  clause: string;
  correctedKey: string | null;
  withSuggestion?: { status: string; synonyms: string[] };
}) {
  const author = await prisma.userProfile.create({
    data: {
      firstName: "T",
      lastName: "A",
      email: `${faker.string.uuid()}@example.test`,
      passwordHash: "x",
    },
  });
  const recipe = await prisma.recipe.create({
    data: {
      name: "R",
      authorId: author.id,
      portions: 4,
      steps: { create: [{ description: options.clause, order: 0 }] },
    },
    include: { steps: true },
  });
  const step = recipe.steps[0];
  if (!step) throw new Error("expected a step");

  const correctedKey = options.correctedKey;
  const correction = await prisma.stepTechStepCorrection.create({
    data: {
      stepId: step.id,
      correctorId: author.id,
      start: 0,
      end: options.clause.length,
      previousTechStepId: null,
      correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
    },
  });

  let suggestion: { id: number } | null = null;
  if (options.withSuggestion && correctedKey !== null) {
    suggestion = await prisma.techStepTrainingSuggestion.create({
      data: {
        techStepId: await techStepId(correctedKey),
        locale: "fr",
        suggestedSynonyms: options.withSuggestion.synonyms,
        suggestedUtterances: [],
        sourceType: "correction",
        sourceCorrectionId: correction.id,
        status: options.withSuggestion.status,
      },
    });
  }
  return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
}

const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;

describe("Admin tech-steps triage", () => {
  const app = createApp();

  beforeEach(async () => {
    await resetDatabase();
  });

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

  async function adminAgent() {
    const { email, password } = await seedAdmin();
    const agent = request.agent(app);
    await agent.post("/admin/auth/login").send({ email, password });
    return agent;
  }

  it("rejects every route without an admin session", async () => {
    for (const path of [
      "/admin/tech-steps/suggestions",
      "/admin/tech-steps/corrections",
      "/admin/tech-steps/training-data-snippet?techStepKey=simmer",
    ]) {
      const res = await request(app).get(path);
      expect(res.status, path).to.equal(401);
    }
    const post = await request(app).post("/admin/tech-steps/retrain").send({});
    expect(post.status).to.equal(401);
  });

  describe("GET /suggestions", () => {
    it("groups suggestions by technique and filters by status", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
        (this as any).skip();
        return;
      }
      await seedCorrectionAndSuggestion({
        clause: "Faire mijoter",
        correctedKey: "simmer",
        withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
      });
      await seedCorrectionAndSuggestion({
        clause: "Émincer les oignons",
        correctedKey: "chop",
        withSuggestion: { status: "applied", synonyms: ["ciseler"] },
      });

      const agent = await adminAgent();

      const all = await agent.get("/admin/tech-steps/suggestions");
      expect(all.status).to.equal(200);
      expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
        "chop",
        "simmer",
      ]);
      const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
      expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");

      const pendingOnly = await agent
        .get("/admin/tech-steps/suggestions")
        .query({ status: "pending" });
      expect(pendingOnly.body).to.have.length(1);
      expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
    });
  });

  describe("PATCH /suggestions/:id", () => {
    it("rejects an empty body with 400", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      const { suggestion } = await seedCorrectionAndSuggestion({
        clause: "Faire mijoter",
        correctedKey: "simmer",
        withSuggestion: { status: "pending", synonyms: ["x"] },
      });
      const agent = await adminAgent();
      const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
      expect(res.status).to.equal(400);
      expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });

    it("404s an unknown id", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      const agent = await adminAgent();
      const res = await agent
        .patch("/admin/tech-steps/suggestions/999999")
        .send({ status: "applied" });
      expect(res.status).to.equal(404);
    });

    it("flips the status and edits the synonyms, reflected in a later GET", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      const { suggestion } = await seedCorrectionAndSuggestion({
        clause: "Faire mijoter",
        correctedKey: "simmer",
        withSuggestion: { status: "pending", synonyms: ["frémir"] },
      });
      const agent = await adminAgent();

      const patched = await agent
        .patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
        .send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
      expect(patched.status).to.equal(200);
      expect(patched.body.status).to.equal("applied");
      expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);

      const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
        where: { id: suggestion?.id },
      });
      expect(stored.status).to.equal("applied");
    });
  });

  describe("GET /corrections", () => {
    it("includes the 'no technique here' removals", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
      await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });

      const agent = await adminAgent();
      const res = await agent.get("/admin/tech-steps/corrections");
      expect(res.status).to.equal(200);
      expect(res.body).to.have.length(2);

      const removals = await agent
        .get("/admin/tech-steps/corrections")
        .query({ hasCorrectedTechStep: "false" });
      expect(removals.body).to.have.length(1);
      expect(removals.body[0].clauseText).to.equal("Rien ici");
      expect(removals.body[0].correctedTechStepKey).to.equal(null);
    });
  });

  describe("GET /training-data-snippet", () => {
    it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      await seedCorrectionAndSuggestion({
        clause: "Faire mijoter",
        correctedKey: "simmer",
        withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
      });
      const agent = await adminAgent();
      const res = await agent
        .get("/admin/tech-steps/training-data-snippet")
        .query({ techStepKey: "simmer", locale: "fr", status: "applied" });
      expect(res.status).to.equal(200);
      expect(res.body.suggestionCount).to.equal(1);
      expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
      expect(res.body.snippet).to.include('"frémir"');
      expect(res.body.snippet).to.include('"synonyms": [');
    });
  });

  describe("POST /retrain", () => {
    it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      this.timeout(60000);
      const agent = await adminAgent();
      const res = await agent.post("/admin/tech-steps/retrain").send({});
      expect(res.status).to.equal(200);
      expect(res.body).to.have.keys([
        "f1",
        "precision",
        "recall",
        "minF1",
        "gatePassed",
        "backfilled",
        "marked",
      ]);
      expect(res.body.minF1).to.equal(0.8);
      if (res.body.gatePassed) {
        expect(res.body.backfilled).to.have.keys(["total", "changed"]);
      }
    });

    it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
      if (!adminSecretConfigured) {
        // biome-ignore lint/suspicious/noExplicitAny: see above.
        (this as any).skip();
        return;
      }
      this.timeout(60000);
      const agent = await adminAgent();
      // supertest requests are lazy — they only dispatch when awaited/then'd.
      // Attaching the `.then` here is what actually fires the first request,
      // so its handler acquires the process-wide lock before the second one
      // (100ms later) checks it. Swallow its result/rejection — this test
      // only asserts on the second request.
      const first = agent
        .post("/admin/tech-steps/retrain")
        .send({})
        .then(
          (res) => res,
          (err) => err,
        );
      await new Promise((resolve) => setTimeout(resolve, 100));
      const second = await agent.post("/admin/tech-steps/retrain").send({});
      expect(second.status).to.equal(409);
      expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
      await first;
    });
  });
});
