import { expect } from "chai";
import {
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";

/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
  globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}

const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";

/**
 * A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
 * reproducing two real 750g.com bugs verified live on the recipe this test's
 * URL/content is modeled after:
 * - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
 *   JSON as-is — this is exactly what {@link sanitizeJsonLdBlocks} in the
 *   adapter under test has to repair before `JSON.parse` can succeed);
 * - `Pr&amp;eacute;parez` — a real "é" that went through 750g's own
 *   HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
 *   fully resolve it back to "é").
 * Plus a plain `&#039;` apostrophe entity in an ingredient line, the more
 * common single-encoding case.
 */
const RAW_RECIPE_JSON_LD = `{
  "@context": "https://schema.org",
  "@type": "Recipe",
  "name": "Poulet au vin jaune et aux morilles",
  "description": "Une recette de f\\u00eate.",
  "image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
  "recipeYield": "6 personnes",
  "recipeIngredient": ["1 poulet fermier", "Sel &#039;fin&#039;"],
  "recipeInstructions": [
    {"@type": "HowToStep", "text": "Pr&amp;eacute;parez les morilles :\r\nFendez-les en deux."}
  ],
  "url": "${RECIPE_URL}"
}`;

function htmlWithRawJsonLd(rawJson: string): string {
  return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
}

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

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

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

  it("declares itself as an unofficial, French-locale source with an icon", () => {
    expect(sevenFiftyGAdapter.key).to.equal("750g");
    expect(sevenFiftyGAdapter.name).to.equal("750g");
    expect(sevenFiftyGAdapter.official).to.equal(false);
    expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
    expect(sevenFiftyGAdapter.locale).to.equal("fr");
  });

  describe("list", () => {
    /**
     * Models the real shape found live: a card's own `<img>` sits
     * immediately before its `<a class="card-link">`, but the fragment also
     * carries decorative images that belong to no card at all (verified
     * live: 28 `<img>` tags against 23 real cards for one sample query) —
     * an image search that isn't "nearest preceding, not naive same-index
     * zip" would misattribute every card after the first stray image.
     */
    const CARDS_HTML = `
      <div class="grid">
        <img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
        <div class="card">
          <img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
          <a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
        </div>
        <img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
        <img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
        <div class="card">
          <img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
          <a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
        </div>
        <div class="card">
          <a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
        </div>
      </div>
    `;

    it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
      stubFetchHtml(CARDS_HTML);

      const result = await sevenFiftyGAdapter.list({ query: "tarte" });

      expect(result.items).to.deep.equal([
        {
          externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
          title: "Tarte aux pommes",
          picture: "https://static.750g.com/images/x/tarte.jpg",
          url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
        },
        {
          externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
          title: "Gratin dauphinois",
          picture: "https://static.750g.com/images/x/gratin.jpg",
          url: "https://www.750g.com/gratin-dauphinois-r2.htm",
        },
        {
          externalId: "https://www.750g.com/pain-perdu-r3.htm",
          title: "Pain perdu",
          picture: null,
          url: "https://www.750g.com/pain-perdu-r3.htm",
        },
      ]);
    });

    it("decodes HTML entities in a card's title", async () => {
      stubFetchHtml(
        `<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes &#039;reinettes&#039;</a>`,
      );

      const result = await sevenFiftyGAdapter.list({ query: "tarte" });

      expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
    });

    it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
      stubFetchHtml(CARDS_HTML);

      const result = await sevenFiftyGAdapter.list({ query: "tarte" });

      expect(result.nextCursor).to.be.null;
    });

    it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
      let requestedUrl: string | undefined;
      globalThis.fetch = (async (url: string) => {
        requestedUrl = url;
        return new Response("", { status: 200 });
      }) as typeof fetch;

      await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });

      expect(requestedUrl).to.include("page=1");
      expect(requestedUrl).not.to.include("page=7");
    });

    it("URL-encodes the query", async () => {
      let requestedUrl: string | undefined;
      globalThis.fetch = (async (url: string) => {
        requestedUrl = url;
        return new Response("", { status: 200 });
      }) as typeof fetch;

      await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });

      expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
    });

    describe("empty/omitted query (browsing with no filter)", () => {
      it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
        let requestedUrl: string | undefined;
        globalThis.fetch = (async (url: string) => {
          requestedUrl = url;
          return new Response(CARDS_HTML, { status: 200 });
        }) as typeof fetch;

        const result = await sevenFiftyGAdapter.list({});

        expect(requestedUrl).to.include("dernieres-recettes.htm");
        expect(requestedUrl).not.to.include("genius/query");
        expect(result.items).to.have.length(3);
      });

      it("also browses for an explicitly empty query string, not just an omitted one", async () => {
        stubFetchHtml(CARDS_HTML);

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

        expect(result.items).to.have.length(3);
      });

      it("requests the given cursor's page", async () => {
        let requestedUrl: string | undefined;
        globalThis.fetch = (async (url: string) => {
          requestedUrl = url;
          return new Response(CARDS_HTML, { status: 200 });
        }) as typeof fetch;

        await sevenFiftyGAdapter.list({ cursor: "5" });

        expect(requestedUrl).to.include("page=5");
      });

      it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
        stubFetchHtml(CARDS_HTML);
        const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
        expect(withItems.nextCursor).to.equal("3");

        stubFetchHtml("<html><body>Plus rien ici</body></html>");
        const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
        expect(empty.nextCursor).to.be.null;
        expect(empty.items).to.deep.equal([]);
      });
    });

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

      try {
        await sevenFiftyGAdapter.list({ query: "x" });
        expect.fail("expected list to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
        expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
      }
    });

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

      try {
        await sevenFiftyGAdapter.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("fetches the given recipe URL and returns its html alongside the url", async () => {
      stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));

      const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);

      expect(result.url).to.equal(RECIPE_URL);
      expect(result.html).to.include("Poulet au vin jaune");
    });

    it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
      stubFetchHtml("", 404);

      try {
        await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
        expect.fail("expected fetchDetail to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceFetchError);
        expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
      }
    });
  });

  describe("parse", () => {
    it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
      const parsed = sevenFiftyGAdapter.parse({
        html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
        url: RECIPE_URL,
      });

      expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
      expect(parsed.steps).to.deep.equal([
        { description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
      ]);
    });

    it("decodes a double HTML-entity-encoded accented character (é -> &eacute; -> &amp;eacute;)", () => {
      const parsed = sevenFiftyGAdapter.parse({
        html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
        url: RECIPE_URL,
      });

      expect(parsed.steps[0]?.description).to.include("Préparez");
    });

    it("decodes a plain numeric apostrophe entity in ingredient text", () => {
      const parsed = sevenFiftyGAdapter.parse({
        html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
        url: RECIPE_URL,
      });

      expect(parsed.ingredients).to.deep.equal([
        { rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
        { rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
      ]);
    });

    it("leaves picture/sourceUrl untouched by entity decoding", () => {
      const parsed = sevenFiftyGAdapter.parse({
        html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
        url: RECIPE_URL,
      });

      expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
      expect(parsed.sourceUrl).to.equal(RECIPE_URL);
    });

    it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
      const clean = {
        "@context": "https://schema.org",
        "@type": "Recipe",
        name: "Tarte aux pommes",
        description: "Une tarte classique.",
        image: "https://static.750g.com/images/tarte.jpg",
        recipeYield: 6,
        recipeIngredient: ["3 pommes", "1 pâte brisée"],
        recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
      };
      const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
        clean,
      )}</script></head><body></body></html>`;

      const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });

      expect(parsed.name).to.equal("Tarte aux pommes");
      expect(parsed.portions).to.equal(6);
      expect(parsed.steps).to.deep.equal([
        { description: "Éplucher les pommes.", picture: null },
        { description: "Enfourner 30 minutes.", picture: null },
      ]);
    });

    it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
      const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";

      try {
        sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
        expect.fail("expected parse to throw");
      } catch (err) {
        expect(err).to.be.instanceOf(RecipeSourceParseError);
        expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
      }
    });
  });
});
