import type {
  ParsedRecipe,
  RecipeSourceAdapter,
  RecipeSourceListItem,
  RecipeSourceListParams,
  RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";

const SOURCE_KEY = "750g";

// 750g.com's own site search is a client-side widget (results are fetched
// by the page's own JS after load, nothing server-rendered to scrape) — but
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family. Only used for a
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
// a blank query with nothing at all.
const SEARCH_URL = "https://www.750g.com/genius/query/";

// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
// zero-length body, so browsing this source with no filter typed would
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
// own "latest recipes" archive: real, server-rendered pagination via
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
// live up to `page=500` — genuinely different recipes every time, no
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
// search, which 404s past its last page), so `list()` treats a page with no
// cards at all as the end-of-results signal instead.
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";

/**
 * Matches every `<script type="application/ld+json">…</script>` block —
 * same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
 * own private copy here rather than sharing that module's export: this one
 * does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
 * sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
 * from extracting-and-parsing blocks into objects.
 */
const JSON_LD_SCRIPT_PATTERN =
  /(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;

/**
 * Escapes any raw (unescaped) JSON control character — U+0000–U+001F —
 * found *inside* a string literal of `json`, leaving everything outside
 * string literals (structural whitespace, brackets, …) untouched. Fixes a
 * real bug in 750g.com's own JSON-LD generator: some `HowToStep.text`
 * values contain a literal, un-escaped `\r\n` where valid JSON requires
 * `\\r\\n` (verified live, e.g.
 * https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and
 * roughly a third of a random sample of recipe pages hit this) —
 * `JSON.parse` throws "Bad control character in string literal" on these
 * pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report
 * "no JSON-LD Recipe found" on a page that has a perfectly good one.
 *
 * A blind find/replace across the whole block would be wrong: JSON also
 * uses real newlines as *structural* whitespace between tokens
 * (pretty-printing), where they're perfectly legal and must be left alone —
 * only walking the text with string-literal awareness (tracking `"…"`
 * boundaries and `\`-escapes) can tell the two apart.
 */
function escapeRawControlCharactersInStrings(json: string): string {
  const SHORT_ESCAPES: Record<string, string> = {
    "\b": "\\b",
    "\f": "\\f",
    "\n": "\\n",
    "\r": "\\r",
    "\t": "\\t",
  };

  let result = "";
  let inString = false;
  let escapedNext = false;
  for (const ch of json) {
    if (!inString) {
      if (ch === '"') inString = true;
      result += ch;
      continue;
    }
    if (escapedNext) {
      result += ch;
      escapedNext = false;
      continue;
    }
    if (ch === "\\") {
      result += ch;
      escapedNext = true;
      continue;
    }
    if (ch === '"') {
      inString = false;
      result += ch;
      continue;
    }
    if (ch < " ") {
      result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
      continue;
    }
    result += ch;
  }
  return result;
}

/**
 * Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD
 * `<script>` block's content in `html`, leaving the rest of the page
 * untouched — the repair step `fetchDetail`'s raw HTML needs before
 * `jsonLdRecipeAdapter.parse` (which does its own extraction/`JSON.parse`
 * internally) ever sees it.
 */
function sanitizeJsonLdBlocks(html: string): string {
  return html.replace(
    JSON_LD_SCRIPT_PATTERN,
    (_match, openTag: string, json: string, closeTag: string) =>
      `${openTag}${escapeRawControlCharactersInStrings(json)}${closeTag}`,
  );
}

/**
 * Numeric entities plus a hand-picked table of named ones — not a general
 * HTML5 entity decoder (400+ named entities exist in the spec), just what's
 * actually been observed necessary to clean up 750g.com's French recipe
 * text: the five basic XML entities, Latin-1 accented letters, and a
 * handful of common punctuation entities.
 */
const NAMED_ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
  nbsp: " ",
  eacute: "é",
  egrave: "è",
  ecirc: "ê",
  euml: "ë",
  agrave: "à",
  acirc: "â",
  auml: "ä",
  icirc: "î",
  iuml: "ï",
  ocirc: "ô",
  ouml: "ö",
  ucirc: "û",
  ugrave: "ù",
  uuml: "ü",
  ccedil: "ç",
  oelig: "œ",
  aelig: "æ",
  laquo: "«",
  raquo: "»",
  lsquo: "‘",
  rsquo: "’",
  ldquo: "“",
  rdquo: "”",
  hellip: "…",
  ndash: "–",
  mdash: "—",
  deg: "°",
};

/** One pass of numeric (`&#39;`/`&#x27;`) and {@link NAMED_ENTITIES} decoding — see {@link decodeHtmlEntities}, which is what actually runs against parsed text; this is split out only so that function can run it twice. */
function decodeHtmlEntitiesOnce(text: string): string {
  return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
    if (body[0] === "#") {
      const isHex = body[1] === "x" || body[1] === "X";
      const codePoint = isHex
        ? Number.parseInt(body.slice(2), 16)
        : Number.parseInt(body.slice(1), 10);
      return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
    }
    return NAMED_ENTITIES[body] ?? entity;
  });
}

/**
 * Decodes HTML entities in free-text pulled from 750g.com, run **twice**:
 * its JSON-LD sometimes double-escapes text that already went through its
 * own HTML-entity encoder once — e.g. a real "é" ends up as `&amp;eacute;`
 * (the `&` of an already-produced `&eacute;` got re-escaped to `&amp;`)
 * rather than a plain `&eacute;` or a raw "é" (verified live, e.g.
 * "Pr&amp;eacute;parez" on
 * https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
 * pass turns that into `&eacute;` — a *newly formed*, valid-looking entity
 * — so a second pass is needed to resolve it the rest of the way to "é". A
 * string with no entities at all (the common case) is unaffected by either
 * pass.
 */
function decodeHtmlEntities(text: string): string {
  return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(text));
}

/**
 * Runs {@link decodeHtmlEntities} over every free-text field of a
 * `ParsedRecipe` produced by `jsonLdRecipeAdapter.parse`. `picture`/
 * `sourceUrl` are deliberately left untouched — they're URLs, not prose,
 * and the entity-encoding bug this fixes has only ever been observed in
 * name/description/instruction/ingredient text, never in a URL field.
 */
function decodeParsedRecipeText(recipe: ParsedRecipe): ParsedRecipe {
  return {
    ...recipe,
    name: decodeHtmlEntities(recipe.name),
    description: recipe.description === null ? null : decodeHtmlEntities(recipe.description),
    ingredients: recipe.ingredients.map((ingredient) => ({
      ...ingredient,
      rawText: decodeHtmlEntities(ingredient.rawText),
      name: decodeHtmlEntities(ingredient.name),
    })),
    steps: recipe.steps.map((step) => ({
      ...step,
      description: decodeHtmlEntities(step.description),
    })),
  };
}

/** One recipe card as scraped off a 750g.com results fragment (search or listing page) — see {@link extractRecipeCards}. */
interface SevenFiftyGCard {
  url: string;
  title: string;
  image: string | null;
}

/**
 * Scrapes every recipe card out of a 750g.com results HTML fragment —
 * there's no JSON-LD `ItemList` on this endpoint to lean on (unlike
 * marmiton.ts's search page), just the same server-rendered `card-recipe`
 * markup 750g.com uses everywhere. Each card's title/url comes from its
 * `<a class="card-link">`; its image is whichever `<img>` most recently
 * preceded that link, rather than a naive same-index zip of "every image on
 * the page" against "every link on the page" — a plain fragment like this
 * one carries a few extra decorative images with no card of their own
 * (verified live: 28 `<img>` tags against 23 real cards for one sample
 * query), which would silently shift every image after the first stray one
 * onto the wrong title. Each card's own `<img>` always sits immediately
 * before its title link in the markup, so "nearest preceding image" is
 * unambiguous and doesn't depend on the two counts matching.
 */
function extractRecipeCards(html: string): SevenFiftyGCard[] {
  const linkPattern =
    /<a\s+href="(https:\/\/www\.750g\.com\/[^"]+)"\s+class="card-link[^"]*">([^<]+)<\/a>/g;
  const imagePattern = /<img[^>]*\ssrc="(https:\/\/static\.750g\.com\/images\/[^"]+)"[^>]*>/g;
  const images = [...html.matchAll(imagePattern)];

  const cards: SevenFiftyGCard[] = [];
  let searchFrom = 0;
  for (const linkMatch of html.matchAll(linkPattern)) {
    let image: string | null = null;
    for (const imgMatch of images) {
      if (imgMatch.index === undefined || imgMatch.index >= linkMatch.index) break;
      if (imgMatch.index >= searchFrom) image = imgMatch[1] ?? null;
    }
    cards.push({
      url: linkMatch[1] ?? "",
      title: decodeHtmlEntities(linkMatch[2] ?? ""),
      image,
    });
    searchFrom = linkMatch.index;
  }
  return cards;
}

/**
 * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
 * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
 * come from this adapter instead (`sourceKey` `"750g"`) — same reasoning as
 * marmiton.ts's identically-named helper: `fetchDetail`/`parse` below are
 * thin wrappers around the generic adapter's own methods, but a caller
 * catching `RecipeSourceError` and reading `.sourceKey` should see "750g",
 * the source it actually asked about.
 */
function rekeySourceError(err: unknown): unknown {
  if (err instanceof RecipeSourceFetchError) {
    return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
  }
  if (err instanceof RecipeSourceParseError) {
    return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
  }
  return err;
}

/**
 * 750g.com — one of France's largest recipe sites. Unofficial (`official:
 * false`): no published API, this adapter fetches ordinary pages and reads
 * the schema.org structured data 750g.com embeds for search engines, built
 * on {@link jsonLdRecipeAdapter} the same way marmiton.ts is. Two real
 * 750g-specific problems separate this adapter from a pure thin wrapper
 * like marmiton.ts, though:
 *
 * - `list()` has no `ItemList` JSON-LD to read off its search results (see
 *   {@link extractRecipeCards}) — its site search is a client-side widget,
 *   so a non-empty query instead calls the plain GET endpoint that widget's
 *   own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
 *   returns a curated batch of cards rather than an exhaustive, paginated
 *   catalog — verified live, requesting `page=2` of the same query always
 *   comes back empty, so `nextCursor` is always `null` in that case, same
 *   as `theMealDbAdapter`'s "one response holds every match". An empty
 *   query reads `LATEST_RECIPES_URL` instead, a real paginated catalog —
 *   `SEARCH_URL` itself answers a blank query with nothing at all, which
 *   would otherwise make browsing this source with no filter typed always
 *   come back empty.
 * - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
 *   marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
 *   this adapter works around: some pages embed literal, unescaped control
 *   characters inside a JSON string (see {@link sanitizeJsonLdBlocks}), and
 *   its free text is sometimes double HTML-entity-encoded (see
 *   {@link decodeParsedRecipeText}). Both are pre/post-processing around the
 *   same underlying delegation, not a reimplementation of it.
 */
export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
  key: SOURCE_KEY,
  name: "750g",
  official: false,
  // Un chemin stable, sans le paramètre `?v=…` de cache-busting que 750g.com
  // ajoute à ses balises <link> (susceptible de changer à chaque
  // déploiement) — cette adresse répond correctement sans lui.
  iconUrl: "https://www.750g.com/img/750g/favicons/favicon.svg",
  // Le contenu de 750g.com (noms, ingrédients, instructions) est en
  // français — détermine contre quel modèle/locale d'étiquettes
  // d'ingrédients translateRecipe (recipe-translation.ts) résout les
  // recettes de cette source lors d'une prévisualisation/d'un import.
  locale: "fr",

  async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
    try {
      const query = params.query ?? "";
      const page = params.cursor ? Number(params.cursor) : 1;
      const hasQuery = query.length > 0;

      // Deux endpoints distincts selon qu'il y a un texte de recherche ou
      // non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` :
      // le premier ne répond rien du tout à une requête vide, le second est
      // le vrai catalogue paginé "dernières recettes" de 750g.com. `page`
      // n'a de sens que pour le second (le premier ne pagine pas — voir
      // plus bas) mais est toujours passé, y compris `page=1`, par
      // cohérence avec le reste de cette famille d'adaptateurs.
      const listUrl = hasQuery
        ? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`
        : `${LATEST_RECIPES_URL}?page=${page}`;

      let response: Response;
      try {
        response = await fetch(listUrl);
      } catch (cause) {
        throw new RecipeSourceFetchError(
          SOURCE_KEY,
          `Network error listing 750g recipes (${listUrl})`,
          {
            cause,
          },
        );
      }
      if (!response.ok) {
        throw new RecipeSourceFetchError(
          SOURCE_KEY,
          `750g responded ${response.status} (${listUrl})`,
        );
      }
      const html = await response.text();

      // No filter for a missing title/url here (unlike marmiton.ts/
      // the-meal-db.ts, which drop entries with a null field from a
      // structured API response) — `extractRecipeCards`' own regex requires
      // at least one character for both, so there's no "absent field" shape
      // to guard against.
      const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
        externalId: card.url,
        title: card.title,
        picture: card.image,
        url: card.url,
      }));

      // La recherche par texte libre ne pagine pas du tout (voir le
      // commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`,
      // même logique que `theMealDbAdapter`. "Dernières recettes" pagine
      // réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page
      // sans aucune carte en est le signal de fin.
      const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null;

      return { items, nextCursor };
    } catch (err) {
      // Rethrown as-is (already keyed "750g" by whichever branch above
      // threw it) — this adapter's only caller (`sources.service.ts`)
      // already handles/logs failures centrally; this method just isn't
      // allowed a bare `async` body without a try/catch per the repo's
      // convention. Same reasoning as `marmiton.ts`/`json-ld-recipe.ts`.
      throw err;
    }
  },

  // `externalId` est directement l'URL canonique de la recette sur
  // 750g.com (renvoyée telle quelle par `list()` ci-dessus) — même
  // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
  // délègue entièrement : la réparation du JSON-LD (voir
  // `sanitizeJsonLdBlocks`) n'a lieu qu'à l'étape `parse()`, pas ici.
  async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
    try {
      return await jsonLdRecipeAdapter.fetchDetail(externalId);
    } catch (err) {
      // Pas un simple re-throw : `rekeySourceError` est le traitement utile
      // que ce point d'appel doit faire de l'erreur (relabelliser sa
      // `sourceKey`), conformément à la convention await/try-catch du repo.
      throw rekeySourceError(err);
    }
  },

  parse(raw: { html: string; url: string }): ParsedRecipe {
    try {
      const sanitizedHtml = sanitizeJsonLdBlocks(raw.html);
      const parsed = jsonLdRecipeAdapter.parse({ html: sanitizedHtml, url: raw.url });
      return decodeParsedRecipeText(parsed);
    } catch (err) {
      throw rekeySourceError(err);
    }
  },
};
