import type { SignupInput } from "@batch-cooking/shared";
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 { prisma } from "../../src/db/prisma.js";
import { syncRecipeSources } from "../../src/db/recipe-source-sync.js";
import type {
  ParsedRecipe,
  RecipeSourceAdapter,
  RecipeSourceListParams,
  RecipeSourceListResult,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../../src/lib/recipe-sources/recipe-source-errors.js";
import {
  clearRecipeSources,
  registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../../test-support/reset-db.js";

/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
  const firstName = faker.person.firstName();
  const lastName = faker.person.lastName();
  return {
    firstName,
    lastName,
    email: faker.internet.email({ firstName, lastName }).toLowerCase(),
    password: faker.internet.password({ length: 16 }),
  };
}

/**
 * A minimal, real English-content fake adapter — `parse()` deliberately
 * mixes one ingredient that resolves against the real seeded catalog
 * ("onion") with one that doesn't ("mystery paste"), and a step whose
 * text matches a real seeded English tech-step mapping ("chop") — same
 * "exercise the real catalog, not a mock of it" approach the ingredient/
 * tech-step matcher tests already use.
 */
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> {
  return {
    key,
    name: "Fake Source",
    official: true,
    iconUrl: null,
    locale: "en",
    async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
      return {
        items: [
          { externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" },
          { externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" },
        ],
        nextCursor: null,
      };
    },
    async fetchDetail(externalId: string): Promise<{ externalId: string }> {
      if (externalId === "missing") {
        throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`);
      }
      return { externalId };
    },
    parse(raw: { externalId: string }): ParsedRecipe {
      return {
        name: `Fake recipe ${raw.externalId}`,
        description: null,
        picture: null,
        portions: 4,
        sourceUrl: `https://fake.test/${raw.externalId}`,
        ingredients: [
          { rawText: "1 onion", quantity: null, unit: null, name: "onion" },
          // No leading number and no recognizable unit word — exercises
          // quantity/unit staying null alongside the ingredient itself not
          // resolving, not just the ingredient.
          { rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" },
        ],
        steps: [{ description: "Chop the onions finely", picture: null }],
      };
    },
  };
}

/**
 * A fake adapter whose two ingredient lines both resolve to the same real
 * seeded ingredient ("onion"), in the same unit (grams) — exercises
 * `previewSourceItem`'s duplicate-merging (`mergeDuplicateIngredients`,
 * see issue #53's follow-up) through the real HTTP endpoint/catalog,
 * rather than only as a pure unit test of the merge function itself.
 */
function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSourceAdapter<{
  externalId: string;
}> {
  return {
    key,
    name: "Fake Source With Duplicates",
    official: true,
    iconUrl: null,
    locale: "en",
    async list(): Promise<RecipeSourceListResult> {
      return { items: [], nextCursor: null };
    },
    async fetchDetail(externalId: string): Promise<{ externalId: string }> {
      return { externalId };
    },
    parse(raw: { externalId: string }): ParsedRecipe {
      return {
        name: `Fake recipe ${raw.externalId}`,
        description: null,
        picture: null,
        portions: 4,
        sourceUrl: `https://fake.test/${raw.externalId}`,
        ingredients: [
          { rawText: "100g Onion", quantity: null, unit: null, name: "onion" },
          { rawText: "50g Onion", quantity: null, unit: null, name: "onion" },
        ],
        steps: [{ description: "Chop the onions finely", picture: null }],
      };
    },
  };
}

/**
 * A minimal French-content fake adapter — same shape as {@link buildFakeAdapter},
 * `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
 * resolving ingredients for a non-English source through the real HTTP
 * endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
 * called only for `locale === "en"`, silently leaving every ingredient
 * unresolved for a French source like Marmiton/750g/Manger Bouger — the
 * regression this test guards against.
 */
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
  return {
    key,
    name: "Fake French Source",
    official: true,
    iconUrl: null,
    locale: "fr",
    async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
      return {
        items: [
          { externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
        ],
        nextCursor: null,
      };
    },
    async fetchDetail(externalId: string): Promise<{ externalId: string }> {
      return { externalId };
    },
    parse(raw: { externalId: string }): ParsedRecipe {
      return {
        name: `Recette factice ${raw.externalId}`,
        description: null,
        picture: null,
        portions: 4,
        sourceUrl: `https://fake.test/${raw.externalId}`,
        ingredients: [
          { rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
          {
            rawText: "un ingrédient mystère",
            quantity: null,
            unit: null,
            name: "ingrédient mystère",
          },
        ],
        steps: [{ description: "Faire mijoter à feu doux", picture: null }],
      };
    },
  };
}

/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
async function ingredientId(key: string): Promise<number> {
  const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
  return ingredient.id;
}

/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
  const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
  return unit.id;
}

describe("Sources", () => {
  const app = createApp();

  /** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */
  async function signupWithHouse(): Promise<{
    agent: ReturnType<typeof request.agent>;
    houseId: number;
  }> {
    const agent = request.agent(app);
    await agent.post("/auth/signup").send(buildSignupPayload());
    const houseRes = await agent.post("/house").send({ name: "Chez moi" });
    return { agent, houseId: houseRes.body.id };
  }

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

  afterEach(() => {
    clearRecipeSources();
  });

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

  describe("GET /sources/:sourceKey/browse", () => {
    it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
      const res = await request(app).get("/sources/fakeSource/browse");
      expect(res.status).to.equal(401);
      expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
    });

    it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => {
      const agent = request.agent(app);
      await agent.post("/auth/signup").send(buildSignupPayload());

      const res = await agent.get("/sources/fakeSource/browse");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
    });

    it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => {
      const { agent } = await signupWithHouse();

      const res = await agent.get("/sources/unknown/browse");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
    });

    it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);

      const res = await agent.get("/sources/fakeSource/browse");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
    });

    it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => {
      const { agent, houseId } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
      await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });

      const importedRecipe = await prisma.recipe.create({
        data: {
          name: "Already imported",
          authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id,
          portions: 4,
          sourceId: source.id,
          externalId: "1",
        },
      });

      const res = await agent.get("/sources/fakeSource/browse");

      expect(res.status).to.equal(200);
      expect(res.body.nextCursor).to.equal(null);
      expect(res.body.items).to.deep.equal([
        {
          externalId: "1",
          title: "Onion soup",
          picture: null,
          url: "https://fake.test/1",
          alreadyImported: true,
          recipeId: importedRecipe.id,
        },
        {
          externalId: "2",
          title: "Mystery stew",
          picture: null,
          url: "https://fake.test/2",
          alreadyImported: false,
          recipeId: null,
        },
      ]);
    });
  });

  describe("GET /sources/:sourceKey/preview/:externalId", () => {
    async function enableFakeSource(): Promise<{
      agent: ReturnType<typeof request.agent>;
    }> {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
      await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      return { agent };
    }

    it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);

      const res = await agent.get("/sources/fakeSource/preview/1");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
    });

    it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => {
      const { agent } = await enableFakeSource();
      const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
      const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });

      const res = await agent.get("/sources/fakeSource/preview/1");

      expect(res.status).to.equal(200);
      expect(res.body).to.deep.include({
        sourceKey: "fakeSource",
        externalId: "1",
        name: "Fake recipe 1",
        description: null,
        picture: null,
        portions: 4,
        sourceUrl: "https://fake.test/1",
      });

      const [resolved, unresolved] = res.body.ingredients;
      expect(resolved.rawText).to.equal("1 onion");
      expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" });
      expect(unresolved.rawText).to.equal("some mystery paste");
      expect(unresolved.ingredient).to.equal(null);
      expect(unresolved.unit).to.equal(null);
      expect(unresolved.quantity).to.equal(null);

      expect(res.body.steps).to.have.length(1);
      const [step] = res.body.steps;
      expect(step.description).to.equal("Chop the onions finely");
      expect(step.techSteps).to.have.length(1);
      expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
      expect(
        step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
      ).to.equal("chop");
    });

    it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => {
      const { agent } = await enableFakeSource();

      const res = await agent.get("/sources/fakeSource/preview/missing");

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
    });

    it("translates a French-locale source's item too, resolving ingredients against the French catalog (previously only 'en' sources ever got matched)", async () => {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFrenchFakeAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeFrSource" } });
      await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
      const piece = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
      const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });

      const res = await agent.get("/sources/fakeFrSource/preview/1");

      expect(res.status).to.equal(200);
      const [resolved, unresolved] = res.body.ingredients;
      expect(resolved.rawText).to.equal("3 carottes");
      expect(resolved.ingredient).to.deep.include({ id: carrot.id, key: "carrot" });
      // No explicit unit word in "3 carottes" — falls back to the generic
      // "piece" unit (see translateRecipeIngredients' own doc comment on
      // issue #53), same as the English fake adapter's "1 onion" would.
      expect(resolved.unit).to.deep.include({ id: piece.id, key: "piece" });
      expect(resolved.quantity).to.equal(3);
      expect(unresolved.rawText).to.equal("un ingrédient mystère");
      expect(unresolved.ingredient).to.equal(null);

      expect(res.body.steps).to.have.length(1);
      expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
        id: simmer.id,
        key: "simmer",
      });
    });

    it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildDuplicateIngredientAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({
        where: { key: "duplicateFakeSource" },
      });
      await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
      const gram = await prisma.unit.findFirstOrThrow({ where: { key: "gram" } });

      const res = await agent.get("/sources/duplicateFakeSource/preview/1");

      expect(res.status).to.equal(200);
      expect(res.body.ingredients).to.have.length(1);
      const [merged] = res.body.ingredients;
      expect(merged.ingredient).to.deep.include({ id: onion.id, key: "onion" });
      expect(merged.unit).to.deep.include({ id: gram.id, key: "gram" });
      expect(merged.quantity).to.equal(150);
      expect(merged.rawText).to.equal("100g Onion + 50g Onion");
    });
  });

  describe("POST /sources/:sourceKey/import/:externalId", () => {
    async function enableFakeSource(): Promise<{
      agent: ReturnType<typeof request.agent>;
      sourceId: number;
    }> {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
      await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      return { agent, sourceId: source.id };
    }

    /** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */
    async function buildImportPayload() {
      return {
        name: "Fake recipe 1 (revue)",
        portions: 4,
        dietIds: [],
        ingredients: [
          { ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
        ],
        steps: [{ description: "Chop the onions finely" }],
      };
    }

    it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
      const res = await request(app)
        .post("/sources/fakeSource/import/1")
        .send(await buildImportPayload());
      expect(res.status).to.equal(401);
      expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
    });

    it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
      const { agent } = await signupWithHouse();
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);

      const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
    });

    it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => {
      const { agent, sourceId } = await enableFakeSource();
      const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });

      const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());

      expect(res.status).to.equal(201);
      const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } });
      expect(created.sourceId).to.equal(sourceId);
      expect(created.externalId).to.equal("1");

      // The step text is English ("Chop the onions finely") — this only
      // matches "chop" if the fake adapter's own locale ("en") was used
      // for tech-step matching, not the hardcoded French default (which
      // would find nothing in English text — see recipe-translation.test.ts's
      // "locales are separate rule sets" test for the same point made the
      // other way around).
      const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } });
      const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } });
      expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]);
    });

    it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => {
      const { agent } = await enableFakeSource();
      const first = await agent
        .post("/sources/fakeSource/import/1")
        .send(await buildImportPayload());
      expect(first.status).to.equal(201);

      const second = await agent
        .post("/sources/fakeSource/import/1")
        .send(await buildImportPayload());

      expect(second.status).to.equal(409);
      expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
    });

    it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => {
      const { agent } = await enableFakeSource();
      const payload = await buildImportPayload();
      payload.ingredients[0].ingredientId = 999_999;

      const res = await agent.post("/sources/fakeSource/import/1").send(payload);

      expect(res.status).to.equal(404);
      expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
    });

    it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
      // A source's raw ingredient lines aren't deduplicated by the matcher
      // (see `ingredient-matcher.ts`) — two different lines (e.g. "Egg
      // Yolks" and "Eggs") can resolve to the same catalog ingredient, same
      // as `recipe.test.ts`'s equivalent for a manual creation, just
      // reached here through the review screen's pre-filled payload
      // instead.
      const { agent } = await enableFakeSource();
      const payload = await buildImportPayload();
      payload.ingredients.push({ ...payload.ingredients[0] });

      const res = await agent.post("/sources/fakeSource/import/1").send(payload);

      expect(res.status).to.equal(400);
      expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
    });

    it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
      // Registers/syncs the adapter once — enableFakeSource() itself does
      // this too, and registerRecipeSource() throws on a duplicate key, so
      // calling it twice in one test (once per household) isn't an option.
      registerRecipeSource(buildFakeAdapter());
      await syncRecipeSources(prisma);
      const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });

      const { agent: firstAgent } = await signupWithHouse();
      await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      const firstImport = await firstAgent
        .post("/sources/fakeSource/import/1")
        .send(await buildImportPayload());
      expect(firstImport.status).to.equal(201);

      const { agent: secondAgent } = await signupWithHouse();
      await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
      const secondImport = await secondAgent
        .post("/sources/fakeSource/import/1")
        .send(await buildImportPayload());

      expect(secondImport.status).to.equal(409);
      expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
    });
  });
});
