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 { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js";

const SOURCE_KEY = "marmiton";
const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx";

/**
 * One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as
 * JSON-LD on its search-results pages — the subset this adapter reads. Also
 * what a search whose term happens to match a known ingredient (e.g.
 * `aqt=poulet`) actually returns: marmiton.org silently serves its
 * ingredient-index page instead of a "search results" page for those terms,
 * but that page embeds the exact same `ItemList` shape, so `list()` doesn't
 * need to tell the two apart.
 */
interface MarmitonListItem {
  "@type"?: string;
  url?: string;
  name?: string;
  image?: string;
}

/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */
interface MarmitonItemList {
  "@type"?: string;
  "@graph"?: unknown[];
  itemListElement?: MarmitonListItem[];
}

/**
 * Finds the first `ItemList` node within one parsed JSON-LD block — mirrors
 * `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type
 * nodes, `@graph` wrapper) but looks for the results listing marmiton.org's
 * search page embeds instead of a `Recipe`.
 */
function findItemListNode(node: unknown): MarmitonItemList | null {
  if (node === null || typeof node !== "object") return null;
  if (Array.isArray(node)) {
    for (const item of node) {
      const found = findItemListNode(item);
      if (found) return found;
    }
    return null;
  }
  const obj = node as MarmitonItemList;
  if (obj["@type"] === "ItemList") return obj;
  if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]);
  return null;
}

/**
 * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
 * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
 * come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/
 * `parse` below are thin wrappers around the generic adapter's own methods
 * (see this module's doc comment) — but a caller catching `RecipeSourceError`
 * and reading `.sourceKey` to attribute a failure to a specific `Source`
 * should see "marmiton", the source it actually asked about, not the
 * internal implementation detail this adapter happens to be built on.
 * Anything else (a bug, an unexpected throw) is passed through unchanged —
 * only the vocabulary this module documents gets relabeled.
 */
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;
}

/**
 * marmiton.org — France's largest recipe site. Unofficial (`official:
 * false`): there's no published API, this adapter fetches ordinary pages and
 * reads the schema.org structured data marmiton.org embeds for search
 * engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first
 * concrete, per-site adapter that generic adapter's own doc comment
 * anticipated ("a concrete adapter for a specific site would use it
 * internally") — `fetchDetail`/`parse` below just delegate straight to it,
 * since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`
 * with nothing site-specific to handle. The only real Marmiton-specific
 * logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to
 * browse, but marmiton.org's search-results page embeds a browsable
 * `ItemList` this adapter reads directly (see {@link findItemListNode}).
 */
export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
  key: SOURCE_KEY,
  name: "Marmiton",
  official: false,
  // Un chemin stable (jamais un nom de fichier avec un hash de build, comme
  // les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son
  // favicon à cette adresse indépendamment de tout déploiement.
  iconUrl: "https://www.marmiton.org/favicon.ico",
  // Le contenu de Marmiton (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 {
      // Un curseur opaque qui encode simplement le numéro de page suivant —
      // marmiton.org pagine sa recherche via `&page=N` (page 1 implicite
      // quand le paramètre est absent), pas de token dédié à faire
      // transiter.
      const page = params.cursor ? Number(params.cursor) : 1;
      const query = params.query ?? "";
      const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${
        page > 1 ? `&page=${page}` : ""
      }`;

      let response: Response;
      try {
        response = await fetch(searchUrl);
      } catch (cause) {
        throw new RecipeSourceFetchError(
          SOURCE_KEY,
          `Network error searching Marmiton (${searchUrl})`,
          { cause },
        );
      }
      // marmiton.org répond 404 dès que `page` dépasse la dernière page de
      // résultats pour cette recherche — pas un vrai échec, juste "il n'y a
      // plus rien" : son `ItemList` ne porte aucun total fiable (son
      // `numberOfItems` vaut toujours la taille de la page courante, jamais
      // le nombre total de résultats) pour le détecter à l'avance autrement
      // qu'en demandant la page suivante et en constatant qu'elle est vide.
      if (response.status === 404) {
        return { items: [], nextCursor: null };
      }
      if (!response.ok) {
        throw new RecipeSourceFetchError(
          SOURCE_KEY,
          `Marmiton search responded ${response.status} (${searchUrl})`,
        );
      }
      const html = await response.text();

      let itemList: MarmitonItemList | null = null;
      for (const block of extractJsonLdBlocks(html)) {
        itemList = findItemListNode(block);
        if (itemList) break;
      }

      const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? [])
        .filter((entry): entry is MarmitonListItem & { url: string; name: string } =>
          Boolean(entry.url && entry.name),
        )
        .map((entry) => ({
          externalId: entry.url,
          title: entry.name,
          picture: entry.image ?? null,
          url: entry.url,
        }));

      return {
        items,
        // Voir le commentaire ci-dessus sur la réponse 404 : une page vide
        // est elle-même le signal de fin, donc on ne propose une page
        // suivante que si celle-ci en a retourné au moins un résultat.
        nextCursor: items.length > 0 ? String(page + 1) : null,
      };
    } catch (err) {
      // Rethrown as-is (already keyed "marmiton" 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 `json-ld-recipe.ts`/`the-meal-db.ts`.
      throw err;
    }
  },

  // `externalId` est directement l'URL canonique de la recette sur
  // marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même
  // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
  // délègue entièrement (voir le commentaire du module).
  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 {
      return jsonLdRecipeAdapter.parse(raw);
    } catch (err) {
      throw rekeySourceError(err);
    }
  },
};
