import { expect } from "chai";
import {
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../../src/sources/json-ld-recipe.js";

/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
function stubFetchHtml(html: string, status = 200) {
  globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}

/** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one `<script type="application/ld+json">` block, optionally alongside `extraBlocks` (e.g. an unrelated `BreadcrumbList`, or deliberately malformed JSON). */
function htmlWithJsonLd(payload: unknown, ...extraBlocks: string[]): string {
  const scripts = [JSON.stringify(payload), ...extraBlocks]
    .map((json) => `<script type="application/ld+json">${json}</script>`)
    .join("\n");
  return `<!doctype html><html><head>${scripts}</head><body></body></html>`;
}

const RECIPE_URL = "https://example.test/recipes/apple-pie";

const baseRecipe = {
  "@context": "https://schema.org",
  "@type": "Recipe",
  name: "Apple Pie",
  description: "A classic apple pie.",
  image: "https://example.test/apple-pie.jpg",
  recipeYield: 8,
  recipeIngredient: ["6 apples, peeled", "200g flour", "  "],
  recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."],
};

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

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

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

  it("declares itself as an unofficial, iconless, browse-less source", () => {
    expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe");
    expect(jsonLdRecipeAdapter.official).to.equal(false);
    expect(jsonLdRecipeAdapter.iconUrl).to.be.null;
  });

  describe("list", () => {
    it("always returns an empty page — this source has no catalog of its own", async () => {
      const result = await jsonLdRecipeAdapter.list({ query: "anything" });
      expect(result).to.deep.equal({ items: [], nextCursor: null });
    });
  });

  describe("fetchDetail", () => {
    it("fetches the given URL and returns its html alongside the url", async () => {
      stubFetchHtml(htmlWithJsonLd(baseRecipe));

      const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);

      expect(result.url).to.equal(RECIPE_URL);
      expect(result.html).to.include("Apple Pie");
    });

    it("throws RecipeSourceFetchError on a non-2xx response", async () => {
      stubFetchHtml("", 404);

      try {
        await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
        expect.fail("expected fetchDetail 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 jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
        expect.fail("expected fetchDetail to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
        expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
      }
    });
  });

  describe("parse", () => {
    function parse(html: string, url = RECIPE_URL) {
      return jsonLdRecipeAdapter.parse({ html, url });
    }

    it("maps a straightforward JSON-LD Recipe end to end", () => {
      const parsed = parse(htmlWithJsonLd(baseRecipe));

      expect(parsed.name).to.equal("Apple Pie");
      expect(parsed.description).to.equal("A classic apple pie.");
      expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg");
      expect(parsed.portions).to.equal(8);
      expect(parsed.sourceUrl).to.equal(RECIPE_URL);
      expect(parsed.steps).to.deep.equal([
        { description: "Peel the apples.", picture: null },
        { description: "Bake at 180°C for 40 minutes.", picture: null },
      ]);
    });

    it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => {
      const parsed = parse(htmlWithJsonLd(baseRecipe));

      expect(parsed.ingredients).to.deep.equal([
        { rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" },
        { rawText: "200g flour", quantity: null, unit: null, name: "200g flour" },
      ]);
    });

    it("prefers the JSON-LD's own url over the fetched url, when present", () => {
      const parsed = parse(
        htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }),
      );
      expect(parsed.sourceUrl).to.equal("https://example.test/canonical");
    });

    it("accepts @type as an array containing Recipe", () => {
      const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] }));
      expect(parsed.name).to.equal("Apple Pie");
    });

    it("finds the Recipe nested under @graph", () => {
      const graphPayload = {
        "@context": "https://schema.org",
        "@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe],
      };
      const parsed = parse(htmlWithJsonLd(graphPayload));
      expect(parsed.name).to.equal("Apple Pie");
    });

    it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => {
      const html = htmlWithJsonLd(
        { "@type": "BreadcrumbList", itemListElement: [] },
        "{ this is not valid json",
        JSON.stringify(baseRecipe),
      );
      const parsed = parse(html);
      expect(parsed.name).to.equal("Apple Pie");
    });

    describe("recipeInstructions shapes", () => {
      it("splits a single free-text string on line breaks", () => {
        const parsed = parse(
          htmlWithJsonLd({
            ...baseRecipe,
            recipeInstructions: "Step one.\nStep two.\n\nStep three.",
          }),
        );
        expect(parsed.steps).to.deep.equal([
          { description: "Step one.", picture: null },
          { description: "Step two.", picture: null },
          { description: "Step three.", picture: null },
        ]);
      });

      it("reads HowToStep objects' text field", () => {
        const parsed = parse(
          htmlWithJsonLd({
            ...baseRecipe,
            recipeInstructions: [
              { "@type": "HowToStep", text: "Peel the apples." },
              { "@type": "HowToStep", text: "Bake." },
            ],
          }),
        );
        expect(parsed.steps).to.deep.equal([
          { description: "Peel the apples.", picture: null },
          { description: "Bake.", picture: null },
        ]);
      });

      it("falls back to a HowToStep's name when it has no text", () => {
        const parsed = parse(
          htmlWithJsonLd({
            ...baseRecipe,
            recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }],
          }),
        );
        expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]);
      });

      it("flattens HowToSections into their nested steps, dropping the section name", () => {
        const parsed = parse(
          htmlWithJsonLd({
            ...baseRecipe,
            recipeInstructions: [
              {
                "@type": "HowToSection",
                name: "Filling",
                itemListElement: [
                  { "@type": "HowToStep", text: "Peel the apples." },
                  { "@type": "HowToStep", text: "Slice them." },
                ],
              },
              {
                "@type": "HowToSection",
                name: "Baking",
                itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }],
              },
            ],
          }),
        );
        expect(parsed.steps).to.deep.equal([
          { description: "Peel the apples.", picture: null },
          { description: "Slice them.", picture: null },
          { description: "Bake at 180°C.", picture: null },
        ]);
      });

      it("throws RecipeSourceParseError when there are no usable instructions", () => {
        expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw(
          RecipeSourceParseError,
        );
        expect(() =>
          parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })),
        ).to.throw(RecipeSourceParseError);
      });
    });

    describe("image shapes", () => {
      it("reads a bare string image", () => {
        const parsed = parse(
          htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }),
        );
        expect(parsed.picture).to.equal("https://example.test/a.jpg");
      });

      it("reads the first entry of an array of ImageObjects", () => {
        const parsed = parse(
          htmlWithJsonLd({
            ...baseRecipe,
            image: [
              { "@type": "ImageObject", url: "https://example.test/large.jpg" },
              { "@type": "ImageObject", url: "https://example.test/small.jpg" },
            ],
          }),
        );
        expect(parsed.picture).to.equal("https://example.test/large.jpg");
      });

      it("is null when there's no image at all", () => {
        const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined }));
        expect(parsed.picture).to.be.null;
      });
    });

    describe("recipeYield shapes", () => {
      it("accepts a plain number", () => {
        expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4);
      });

      it("extracts the leading integer from a free-text string", () => {
        expect(
          parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions,
        ).to.equal(4);
      });

      it("reads the first entry of an array", () => {
        expect(
          parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions,
        ).to.equal(6);
      });

      it("is null when absent or unparseable", () => {
        expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be
          .null;
        expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null;
      });
    });

    describe("failure cases", () => {
      it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => {
        expect(() => parse("<html><body>No structured data here</body></html>")).to.throw(
          RecipeSourceParseError,
        );
      });

      it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => {
        const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] });
        expect(() => parse(html)).to.throw(RecipeSourceParseError);
      });

      it("throws RecipeSourceParseError when the Recipe has no name", () => {
        const html = htmlWithJsonLd({ ...baseRecipe, name: undefined });
        expect(() => parse(html)).to.throw(RecipeSourceParseError);
      });
    });
  });
});
