import { expect } from "chai";
import {
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { type TheMealDbMeal, theMealDbAdapter } from "../../src/sources/the-meal-db.js";

/**
 * Stubs `globalThis.fetch` for one test — no HTTP-mocking library exists
 * in this codebase yet (this is the first module that talks to a real
 * external network), and a single reassignable global covers the handful
 * of call shapes this adapter needs without adding a new dependency.
 * Restored by the `afterEach` below regardless of which test used it.
 */
function stubFetch(body: unknown, status = 200) {
  globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
}

const baseMeal: TheMealDbMeal = {
  idMeal: "52795",
  strMeal: "Chicken Handi",
  strMealThumb: "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg",
  strInstructions: "Step one.\r\nStep two.\r\n\r\nStep three.",
  strIngredient1: "Chicken",
  strMeasure1: "1 kg",
  strIngredient2: "  ",
  strMeasure2: "2 tbsp",
  strIngredient3: "Onion",
  strMeasure3: "",
};

describe("theMealDbAdapter", () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("declares itself as an official source, with a key/name/icon", () => {
    expect(theMealDbAdapter.key).to.equal("theMealDb");
    expect(theMealDbAdapter.name).to.equal("TheMealDB");
    expect(theMealDbAdapter.official).to.equal(true);
    expect(theMealDbAdapter.iconUrl).to.be.a("string");
  });

  describe("list", () => {
    it("maps search results into RecipeSourceListItems", async () => {
      stubFetch({
        meals: [
          { idMeal: "1", strMeal: "Test Meal", strMealThumb: "https://example.test/thumb.jpg" },
        ],
      });

      const result = await theMealDbAdapter.list({ query: "test" });

      expect(result.items).to.deep.equal([
        {
          externalId: "1",
          title: "Test Meal",
          picture: "https://example.test/thumb.jpg",
          url: "https://www.themealdb.com/meal/1",
        },
      ]);
      expect(result.nextCursor).to.be.null;
    });

    it("returns an empty list when the API responds with meals: null", async () => {
      stubFetch({ meals: null });

      const result = await theMealDbAdapter.list({ query: "doesnotexist" });

      expect(result.items).to.deep.equal([]);
      expect(result.nextCursor).to.be.null;
    });

    it("skips a meal with no name rather than surfacing a titleless item", async () => {
      stubFetch({ meals: [{ idMeal: "1", strMeal: null, strMealThumb: null }] });

      const result = await theMealDbAdapter.list({ query: "x" });

      expect(result.items).to.deep.equal([]);
    });

    it("throws RecipeSourceFetchError on a non-2xx response", async () => {
      stubFetch({}, 500);

      try {
        await theMealDbAdapter.list({ query: "x" });
        expect.fail("expected list to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
      }
    });

    it("throws RecipeSourceFetchError when the network request itself fails", async () => {
      globalThis.fetch = (async () => {
        throw new Error("network down");
      }) as typeof fetch;

      try {
        await theMealDbAdapter.list({ query: "x" });
        expect.fail("expected list to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
        expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
      }
    });
  });

  describe("fetchDetail", () => {
    it("returns the first meal from the lookup response", async () => {
      stubFetch({ meals: [baseMeal] });

      const result = await theMealDbAdapter.fetchDetail("52795");

      expect(result).to.deep.equal(baseMeal);
    });

    it("throws RecipeSourceFetchError when no meal matches the id", async () => {
      stubFetch({ meals: null });

      try {
        await theMealDbAdapter.fetchDetail("999999");
        expect.fail("expected fetchDetail to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
      }
    });
  });

  describe("parse", () => {
    it("maps name/picture/sourceUrl and splits instructions into steps", () => {
      const parsed = theMealDbAdapter.parse(baseMeal);

      expect(parsed.name).to.equal("Chicken Handi");
      expect(parsed.description).to.be.null;
      expect(parsed.picture).to.equal(baseMeal.strMealThumb);
      expect(parsed.portions).to.be.null;
      expect(parsed.sourceUrl).to.equal("https://www.themealdb.com/meal/52795");
      expect(parsed.steps).to.deep.equal([
        { description: "Step one.", picture: null },
        { description: "Step two.", picture: null },
        { description: "Step three.", picture: null },
      ]);
    });

    it("skips blank ingredient slots and keeps the measure alongside the name in rawText", () => {
      const parsed = theMealDbAdapter.parse(baseMeal);

      expect(parsed.ingredients).to.deep.equal([
        { rawText: "1 kg Chicken", quantity: null, unit: null, name: "Chicken" },
        { rawText: "Onion", quantity: null, unit: null, name: "Onion" },
      ]);
    });

    it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
      const parsed = theMealDbAdapter.parse({
        ...baseMeal,
        strInstructions:
          "For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
      });

      expect(parsed.steps).to.deep.equal([
        { description: "For the caramel, melt the sugar.", picture: null },
        { description: "Preheat the oven.", picture: null },
        { description: "Bake it.", picture: null },
      ]);
    });

    it("throws RecipeSourceParseError when the meal has no name", () => {
      expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
        RecipeSourceParseError,
      );
    });

    it("throws RecipeSourceParseError when there are no usable instructions", () => {
      expect(() =>
        theMealDbAdapter.parse({ ...baseMeal, strInstructions: "   \r\n\r\n  " }),
      ).to.throw(RecipeSourceParseError);
    });

    it("throws RecipeSourceParseError when instructions are null", () => {
      expect(() => theMealDbAdapter.parse({ ...baseMeal, strInstructions: null })).to.throw(
        RecipeSourceParseError,
      );
    });
  });
});
