import { expect } from "chai";
import type {
  ParsedRecipe,
  RecipeSourceAdapter,
  RecipeSourceListItem,
  RecipeSourceListParams,
  RecipeSourceListResult,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import {
  RecipeSourceError,
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import {
  clearRecipeSources,
  getRecipeSource,
  listRecipeSources,
  registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";

interface FakeRawRecipe {
  externalId: string;
  title: string;
  servings: number;
  ingredientLines: string[];
  instructionLines: string[];
}

const FAKE_CATALOG: FakeRawRecipe[] = [
  {
    externalId: "1",
    title: "Tarte aux pommes",
    servings: 6,
    ingredientLines: ["3 pommes", "200 g de farine"],
    instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"],
  },
  {
    externalId: "2",
    title: "Soupe de légumes",
    servings: 4,
    ingredientLines: ["2 carottes"],
    instructionLines: ["Mijoter 20 minutes"],
  },
  {
    externalId: "3",
    title: "Salade César",
    servings: 2,
    ingredientLines: ["1 salade"],
    instructionLines: ["Mélanger"],
  },
];

const PAGE_SIZE = 2;

/** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */
function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe> {
  return {
    key,
    name: "Fake Source",
    official: false,
    iconUrl: null,
    locale: "fr",
    async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
      const start = params.cursor ? Number(params.cursor) : 0;
      const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
      const nextStart = start + PAGE_SIZE;
      return {
        items: page.map((recipe) => ({
          externalId: recipe.externalId,
          title: recipe.title,
          picture: null,
          url: `https://fake.test/recipes/${recipe.externalId}`,
        })),
        nextCursor: nextStart < FAKE_CATALOG.length ? String(nextStart) : null,
      };
    },
    async fetchDetail(externalId: string): Promise<FakeRawRecipe> {
      const found = FAKE_CATALOG.find((recipe) => recipe.externalId === externalId);
      if (!found) throw new RecipeSourceFetchError(key, `Unknown recipe ${externalId}`);
      return found;
    },
    parse(raw: FakeRawRecipe): ParsedRecipe {
      return {
        name: raw.title,
        description: null,
        picture: null,
        portions: raw.servings,
        sourceUrl: `https://fake.test/recipes/${raw.externalId}`,
        ingredients: raw.ingredientLines.map((line) => ({
          rawText: line,
          quantity: null,
          unit: null,
          name: line,
        })),
        steps: raw.instructionLines.map((line) => ({ description: line, picture: null })),
      };
    },
  };
}

describe("recipe-source", () => {
  afterEach(() => {
    clearRecipeSources();
  });

  describe("registry", () => {
    it("registers and retrieves an adapter by key", () => {
      const adapter = buildFakeAdapter();
      registerRecipeSource(adapter);
      expect(getRecipeSource("fakeSource")).to.equal(adapter);
    });

    it("returns undefined for an unregistered key", () => {
      expect(getRecipeSource("unknown")).to.be.undefined;
    });

    it("lists every registered adapter", () => {
      registerRecipeSource(buildFakeAdapter("fakeSource"));
      registerRecipeSource(buildFakeAdapter("otherSource"));

      expect(
        listRecipeSources()
          .map((adapter) => adapter.key)
          .sort(),
      ).to.deep.equal(["fakeSource", "otherSource"]);
    });

    it("rejects registering the same key twice", () => {
      registerRecipeSource(buildFakeAdapter());
      expect(() => registerRecipeSource(buildFakeAdapter())).to.throw(/already registered/);
    });

    it("clearRecipeSources empties the registry", () => {
      registerRecipeSource(buildFakeAdapter());
      clearRecipeSources();
      expect(listRecipeSources()).to.deep.equal([]);
    });
  });

  describe("adapter contract (via a fake adapter)", () => {
    it("browses in pages until nextCursor is null", async () => {
      const adapter = buildFakeAdapter();

      const firstPage = await adapter.list({});
      expect(firstPage.items.map((item) => item.externalId)).to.deep.equal(["1", "2"]);
      expect(firstPage.nextCursor).to.equal("2");

      const secondPage = await adapter.list({ cursor: firstPage.nextCursor });
      expect(secondPage.items.map((item) => item.externalId)).to.deep.equal(["3"]);
      expect(secondPage.nextCursor).to.be.null;
    });

    it("filters by query the same way, when the source supports it (fake adapter ignores it — only pagination is exercised here)", async () => {
      const adapter = buildFakeAdapter();
      const res = await adapter.list({ query: "tarte" });
      // Documents that `query` is a valid, optional param even though this
      // particular fake doesn't act on it — a real adapter would filter.
      expect(res.items).to.have.length(2);
    });

    it("fetches the detail for a selected item, then parses it into a ParsedRecipe", async () => {
      const adapter = buildFakeAdapter();

      const raw = await adapter.fetchDetail("1");
      const parsed = adapter.parse(raw);

      expect(parsed.name).to.equal("Tarte aux pommes");
      expect(parsed.description).to.be.null;
      expect(parsed.portions).to.equal(6);
      expect(parsed.sourceUrl).to.equal("https://fake.test/recipes/1");
      expect(parsed.ingredients).to.have.length(2);
      expect(parsed.ingredients[0]).to.deep.equal({
        rawText: "3 pommes",
        quantity: null,
        unit: null,
        name: "3 pommes",
      });
      expect(parsed.steps).to.deep.equal([
        { description: "Éplucher les pommes", picture: null },
        { description: "Cuire 30 minutes", picture: null },
      ]);
    });

    it("throws RecipeSourceFetchError for an unknown externalId", async () => {
      const adapter = buildFakeAdapter();

      try {
        await adapter.fetchDetail("does-not-exist");
        expect.fail("expected fetchDetail to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
        expect((err as RecipeSourceFetchError).sourceKey).to.equal("fakeSource");
      }
    });
  });

  describe("markAlreadyImported", () => {
    const items: RecipeSourceListItem[] = [
      {
        externalId: "1",
        title: "Tarte aux pommes",
        picture: null,
        url: "https://fake.test/recipes/1",
      },
      {
        externalId: "2",
        title: "Soupe de légumes",
        picture: null,
        url: "https://fake.test/recipes/2",
      },
      { externalId: "3", title: "Salade César", picture: null, url: "https://fake.test/recipes/3" },
    ];

    it("flags items whose externalId is in the imported set, leaves the rest false", () => {
      const result = markAlreadyImported(items, new Set(["1", "3"]));

      expect(
        result.map((item) => ({
          externalId: item.externalId,
          alreadyImported: item.alreadyImported,
        })),
      ).to.deep.equal([
        { externalId: "1", alreadyImported: true },
        { externalId: "2", alreadyImported: false },
        { externalId: "3", alreadyImported: true },
      ]);
    });

    it("flags nothing when the imported set is empty", () => {
      const result = markAlreadyImported(items, new Set());
      expect(result.every((item) => item.alreadyImported === false)).to.be.true;
    });

    it("returns an empty list unchanged", () => {
      expect(markAlreadyImported([], new Set(["1"]))).to.deep.equal([]);
    });

    it("preserves every field from the original item alongside the new flag", () => {
      const [first] = markAlreadyImported([items[0]], new Set(["1"]));
      expect(first).to.deep.equal({ ...items[0], alreadyImported: true });
    });

    it("doesn't mutate the input items", () => {
      const snapshot = structuredClone(items);
      markAlreadyImported(items, new Set(["1"]));
      expect(items).to.deep.equal(snapshot);
    });
  });

  describe("RecipeSourceError hierarchy", () => {
    it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => {
      const cause = new Error("network down");
      const err = new RecipeSourceFetchError("fakeSource", "could not reach source", { cause });

      expect(err).to.be.instanceOf(Error);
      expect(err).to.be.instanceOf(RecipeSourceError);
      expect(err.name).to.equal("RecipeSourceFetchError");
      expect(err.sourceKey).to.equal("fakeSource");
      expect(err.message).to.equal("could not reach source");
      expect(err.cause).to.equal(cause);
    });

    it("RecipeSourceParseError carries the source key and works without a cause", () => {
      const err = new RecipeSourceParseError("fakeSource", "unexpected shape");

      expect(err).to.be.instanceOf(RecipeSourceError);
      expect(err.name).to.equal("RecipeSourceParseError");
      expect(err.sourceKey).to.equal("fakeSource");
      expect(err.cause).to.be.undefined;
    });
  });
});
