import type {
  ParsedRecipe,
  ParsedRecipeIngredient,
  RecipeSourceAdapter,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
  RecipeSourceFetchError,
  RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";

const SOURCE_KEY = "jsonLdRecipe";

/**
 * Matches every `<script type="application/ld+json">…</script>` block in a
 * page — a plain regex rather than a full HTML parser (no such dependency
 * exists in this codebase yet): a `<script>` tag's content is never nested
 * HTML, so a non-greedy match reliably captures each block whole without
 * needing real DOM parsing.
 */
const JSON_LD_SCRIPT_PATTERN =
  /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;

/** schema.org's `image` property — a bare URL, one `ImageObject`, or an array of either. */
type SchemaOrgImage = string | { url?: string } | Array<string | { url?: string }>;

/** One `HowToStep`, or a `HowToSection` grouping several of them under `itemListElement` — schema.org's two shapes for `recipeInstructions` array entries. */
interface SchemaOrgHowToStep {
  "@type"?: string;
  text?: string;
  name?: string;
  itemListElement?: SchemaOrgHowToStep[];
}

/** The subset of schema.org's `Recipe` type this adapter reads — every field is optional per the spec, sites vary in how much they fill in. */
interface SchemaOrgRecipe {
  "@type"?: string | string[];
  "@graph"?: unknown[];
  name?: string;
  description?: string;
  image?: SchemaOrgImage;
  recipeYield?: string | number | Array<string | number>;
  recipeIngredient?: string[];
  recipeInstructions?: string | Array<string | SchemaOrgHowToStep>;
  url?: string;
}

/**
 * Extracts and JSON-parses every JSON-LD block on the page — a block that
 * fails to parse is skipped rather than failing the whole page over one
 * malformed script tag (some sites ship more than one JSON-LD block, e.g.
 * `BreadcrumbList` alongside `Recipe`). Exported (not just consumed
 * internally by {@link findRecipeNode} below) so a concrete per-site adapter
 * built on top of this module — e.g. `marmiton.ts`, which needs the same
 * page's embedded `ItemList` rather than its `Recipe` — reuses this same
 * extraction step instead of re-implementing the `<script>`-block regex.
 */
export function extractJsonLdBlocks(html: string): unknown[] {
  const blocks: unknown[] = [];
  for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
    try {
      blocks.push(JSON.parse((match[1] ?? "").trim()));
    } catch {
      // Malformed JSON-LD — not our problem to fix, just skip it.
    }
  }
  return blocks;
}

function hasRecipeType(type: SchemaOrgRecipe["@type"]): boolean {
  return type === "Recipe" || (Array.isArray(type) && type.includes("Recipe"));
}

/**
 * Finds the first `Recipe`-typed node within one parsed JSON-LD block —
 * handles a bare `Recipe` object, an array of mixed-type nodes (a page
 * commonly ships `Recipe` alongside `BreadcrumbList`/`WebPage`/…), and the
 * `@graph` wrapper some sites nest everything under.
 */
function findRecipeNode(node: unknown): SchemaOrgRecipe | null {
  if (node === null || typeof node !== "object") return null;
  if (Array.isArray(node)) {
    for (const item of node) {
      const found = findRecipeNode(item);
      if (found) return found;
    }
    return null;
  }
  const obj = node as SchemaOrgRecipe;
  if (hasRecipeType(obj["@type"])) return obj;
  if (Array.isArray(obj["@graph"])) return findRecipeNode(obj["@graph"]);
  return null;
}

/** `image` can be a bare URL, an `ImageObject`, or an array of either (usually several resolutions of the same picture) — this just wants one usable URL, the first it finds. */
function extractImageUrl(image: SchemaOrgImage | undefined): string | null {
  if (!image) return null;
  if (typeof image === "string") return image;
  if (Array.isArray(image)) return image.length > 0 ? extractImageUrl(image[0]) : null;
  return image.url ?? null;
}

/** `recipeYield` is meant to be a serving count but schema.org also allows a free-text string (`"4 servings"`) or an array of either — pulls the first integer out of whatever's there, `null` if none can be found. */
function extractPortions(recipeYield: SchemaOrgRecipe["recipeYield"]): number | null {
  const raw = Array.isArray(recipeYield) ? recipeYield[0] : recipeYield;
  if (raw === undefined || raw === null) return null;
  if (typeof raw === "number") return Number.isFinite(raw) ? Math.trunc(raw) : null;
  const match = raw.match(/\d+/);
  return match ? Number(match[0]) : null;
}

/**
 * `recipeIngredient` is already a flat array of free-text lines — the
 * closest schema.org gets to `ParsedRecipeIngredient`. Unlike TheMealDB's
 * separate name/measure fields, there's nothing here to cleanly split a
 * quantity/unit away from the ingredient name, so `name` just repeats the
 * whole line (same fallback every other free-text-only field in this
 * codebase uses when it can't do better).
 */
function extractIngredients(lines: string[] | undefined): ParsedRecipeIngredient[] {
  return (lines ?? [])
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .map((line) => ({ rawText: line, quantity: null, unit: null, name: line }));
}

/**
 * `recipeInstructions` is the most inconsistent field across real sites:
 * one unbroken string, an array of plain strings, an array of `HowToStep`
 * objects (`text`, sometimes `name` instead), or `HowToStep`s grouped into
 * named `HowToSection`s via a nested `itemListElement` — this flattens
 * every shape into a plain ordered list of step descriptions.
 */
function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]): string[] {
  if (!instructions) return [];
  if (typeof instructions === "string") {
    // A single field crammed with every step — the closest split available
    // is by line, same approach TheMealDB's own free-text instructions use.
    return instructions
      .split(/\r?\n+/)
      .map((line) => line.trim())
      .filter((line) => line.length > 0);
  }
  const steps: string[] = [];
  for (const item of instructions) {
    if (typeof item === "string") {
      const trimmed = item.trim();
      if (trimmed.length > 0) steps.push(trimmed);
      continue;
    }
    if (Array.isArray(item.itemListElement)) {
      steps.push(...flattenInstructions(item.itemListElement));
      continue;
    }
    const text = item.text ?? item.name;
    if (text && text.trim().length > 0) steps.push(text.trim());
  }
  return steps;
}

/**
 * Generic recipe source: given any recipe page's URL, fetches it and reads
 * the schema.org `Recipe` structured data most recipe sites embed as
 * JSON-LD for search engines (Google Rich Results, etc.) — one adapter
 * covering a large share of recipe sites, rather than one hand-built
 * adapter per site.
 *
 * `official: false` — even though JSON-LD is the site's own published
 * structured data (not markup we're inferring meaning from), this adapter
 * has no dedicated relationship with any one site: it fetches an arbitrary
 * page and reads embedded metadata, not a maintained API endpoint a
 * publisher operates and supports. That's closer to "unofficial" than
 * "official" under this app's own distinction (see `official`'s doc
 * comment on `RecipeSourceAdapter`).
 *
 * Has no catalog of its own to browse — `list()` always returns nothing;
 * `fetchDetail`'s `externalId` is simply the target URL itself, not an id
 * from a prior `list()` call. A future "import from URL" flow would call
 * `fetchDetail(pastedUrl)` directly.
 *
 * `marmiton.ts`'s `marmitonAdapter` is the first concrete adapter built on
 * top of this one — its `fetchDetail`/`parse` delegate straight here (a
 * marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
 * site-specific to handle), and it only adds the `list()` this adapter
 * itself can't offer, by reading the separate `ItemList` marmiton.org embeds
 * on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
 * `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
 * own sites, but each wraps this adapter's own `parse()` (rather than
 * delegating untouched) to work around real bugs/gaps in that site's own
 * JSON-LD — see each module's doc comment.
 */
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
  html: string;
  url: string;
}> = {
  key: SOURCE_KEY,
  name: "Import générique (JSON-LD)",
  official: false,
  iconUrl: null,
  // Genuinely varies per scraped site (this adapter has no fixed content
  // language of its own) — "fr" as a placeholder since it's never actually
  // consulted: this adapter isn't registered into the source registry (see
  // sources/index.ts), so nothing calls translateRecipe against it today.
  // A concrete per-site adapter built on top of this one would declare its
  // own real locale.
  locale: "fr",

  async list() {
    try {
      return { items: [], nextCursor: null };
    } catch (err) {
      // Rethrown as-is — 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.
      throw err;
    }
  },

  async fetchDetail(url: string): Promise<{ html: string; url: string }> {
    try {
      let response: Response;
      try {
        response = await fetch(url);
      } catch (cause) {
        throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
      }
      if (!response.ok) {
        throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
      }
      const html = await response.text();
      return { html, url };
    } catch (err) {
      throw err; // see list()'s catch comment above
    }
  },

  parse({ html, url }): ParsedRecipe {
    let recipe: SchemaOrgRecipe | null = null;
    for (const block of extractJsonLdBlocks(html)) {
      recipe = findRecipeNode(block);
      if (recipe) break;
    }
    if (!recipe) {
      throw new RecipeSourceParseError(SOURCE_KEY, `No JSON-LD Recipe found at ${url}`);
    }
    if (!recipe.name) {
      throw new RecipeSourceParseError(SOURCE_KEY, `JSON-LD Recipe at ${url} has no name`);
    }

    const steps = flattenInstructions(recipe.recipeInstructions).map((description) => ({
      description,
      picture: null,
    }));
    if (steps.length === 0) {
      throw new RecipeSourceParseError(
        SOURCE_KEY,
        `JSON-LD Recipe "${recipe.name}" at ${url} has no usable instructions`,
      );
    }

    return {
      name: recipe.name,
      description: recipe.description ?? null,
      picture: extractImageUrl(recipe.image),
      portions: extractPortions(recipe.recipeYield),
      sourceUrl: recipe.url ?? url,
      ingredients: extractIngredients(recipe.recipeIngredient),
      steps,
    };
  },
};
