/**
 * RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
 * Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
 * API-Responses werden in .cache/cms gespeichert; bei nicht erreichbarem CMS wird aus dem Cache gelesen.
 *
 * Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests).
 * Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec.
 * Nach Schema-Änderungen im CMS: `yarn generate:api-types` (RustyCMS muss laufen).
 */

import type { components, operations } from "./cms-api.generated";

/** Lazy-Import nur bei SSR – cms-cache (Node-only) darf nicht ins Client-Bundle. */
let cachedFetchFn: ((url: string) => Promise<Response>) | null = null;
async function getCachedFetch(): Promise<(url: string) => Promise<Response>> {
  if (!cachedFetchFn) {
    if (import.meta.env.SSR) {
      // In Dev immer direkt zur API, damit sofort sichtbar ist, was die API liefert (kein Disk-Cache).
      if (import.meta.env.DEV) {
        cachedFetchFn = (url: string) => fetch(url);
      } else {
        const m = await import("./cms-cache");
        cachedFetchFn = m.cachedFetch;
      }
    } else {
      cachedFetchFn = (url: string) => fetch(url);
    }
  }
  return cachedFetchFn;
}

const getBaseUrl = (): string => {
  const url = import.meta.env.PUBLIC_CMS_URL;
  const base = (
    typeof url === "string" && url.trim() ? url : "http://localhost:3000"
  ).replace(/\/$/, "");
  return base;
};

/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
export type PageEntry = components["schemas"]["page"];

/** Antwort von GET /api/content/page (aus OpenAPI-Spec generiert). */
export type PageListResponse =
  operations["listPage"]["responses"][200]["content"]["application/json"];

/** Page-Config (Site-Einstellungen, wie in windwiderstand). Optional: homePage für Startseiten-Slug. */
export type PageConfigEntry = components["schemas"]["page_config"] & {
  /** Referenz auf die Page für die Startseite (Slug oder { _slug }). */
  homePage?: string | { _slug?: string };
};
export type PageConfigListResponse =
  operations["listPageConfig"]["responses"][200]["content"]["application/json"];

export type OpenApiSpec = {
  openapi: string;
  info: { title: string; version: string };
  paths: Record<string, unknown>;
  components?: { schemas?: Record<string, unknown> };
};

let openApiCache: OpenApiSpec | null = null;

/** Einfacher Request-Cache: gleiche Aufrufe (pro Prozess) nur einmal ausführen. Reduziert doppelte CMS-Requests bei Layout + Page. */
const listCache = new Map<string, Promise<unknown>>();
/** In Dev keinen List-Cache, damit neue CMS-Inhalte sofort erscheinen. */
const skipListCache = typeof import.meta.env !== "undefined" && import.meta.env.DEV;

/**
 * Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
 * Wird gecacht (einmal pro Request/Build).
 */
export async function fetchOpenApi(): Promise<OpenApiSpec> {
  if (openApiCache) return openApiCache;
  const base = getBaseUrl();
  const res = await (await getCachedFetch())(`${base}/api-docs/openapi.json`);
  if (!res.ok) {
    throw new Error(
      `RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
    );
  }
  const spec = (await res.json()) as OpenApiSpec;
  openApiCache = spec;
  return spec;
}

/**
 * Alle Page-Einträge (GET /api/content/page). Lädt alle Seiten (paginiert), damit
 * getStaticPaths ([slug]) auch jenseits der API-Standard-Seitengröße (50) vollständig ist.
 */
export async function getPages(options?: { locale?: string }): Promise<PageEntry[]> {
  const locale = options?.locale ?? "de";
  const key = `getPages:${locale}`;
  if (!skipListCache) {
    const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
    if (p) return p;
  }
  const p = (async () => {
    const base = getBaseUrl();
    const perPage = 200;
    const all: PageEntry[] = [];
    let page = 1;
    let totalPages = 1;
    while (page <= totalPages) {
      const url = new URL(`${base}/api/content/page`);
      url.searchParams.set("_locale", locale);
      url.searchParams.set("_page", String(page));
      url.searchParams.set("_per_page", String(perPage));
      const res = await (await getCachedFetch())(url.toString());
      if (!res.ok) {
        throw new Error(
          `RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
        );
      }
      const data = (await res.json()) as PageListResponse;
      const items = data.items ?? [];
      all.push(...items);
      totalPages = Math.max(1, data.total_pages ?? 1);
      page += 1;
    }
    return all;
  })();
  if (!skipListCache) listCache.set(key, p);
  return p;
}

/**
 * Alle page_config-Einträge (GET /api/content/page_config).
 * Wie in windwiderstand: eine Config (z. B. slug "default") mit u. a. homePage.
 */
export async function getPageConfigs(options?: {
  locale?: string;
  per_page?: number;
}): Promise<PageConfigEntry[]> {
  const base = getBaseUrl();
  const url = new URL(`${base}/api/content/page_config`);
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  url.searchParams.set("_per_page", String(options?.per_page ?? 50));
  const res = await (await getCachedFetch())(url.toString());
  if (!res.ok) {
    throw new Error(
      `RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
    );
  }
  const data = (await res.json()) as PageConfigListResponse;
  return (data.items ?? []) as PageConfigEntry[];
}

/**
 * Page-Config anhand Slug (z. B. "default"). Gecacht pro Prozess (Key: slug + options).
 */
export async function getPageConfigBySlug(
  slug: string,
  options?: { locale?: string; resolve?: string },
): Promise<PageConfigEntry | null> {
  const opts = options ?? {};
  const key = `getPageConfigBySlug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
  if (!skipListCache) {
    const p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
    if (p) return p;
  }
  const p = (async () => {
    const base = getBaseUrl();
    const url = new URL(
      `${base}/api/content/page_config/${encodeURIComponent(slug)}`,
    );
    if (opts.locale) url.searchParams.set("_locale", opts.locale);
    if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
    const res = await (await getCachedFetch())(url.toString());
    if (res.status === 404) return null;
    if (!res.ok) {
      throw new Error(
        `RustyCMS get page_config failed: ${res.status} for slug "${slug}".`,
      );
    }
    return (await res.json()) as PageConfigEntry;
  })();
  if (!skipListCache) listCache.set(key, p);
  return p;
}

/**
 * Startseiten-Slug aus page_config (wie windwiderstand).
 * Versucht zuerst page-config-default, dann default, sonst erstes Config-Item aus der Liste.
 * Liefert homePage als Slug (String oder ref._slug), sonst null → Fallback auf Konstante.
 */
export async function getHomePageSlugFromConfig(options?: {
  locale?: string;
}): Promise<string | null> {
  const config =
    (await getPageConfigBySlug("page-config-default", options)) ??
    (await getPageConfigBySlug("default", options)) ??
    (await getPageConfigs({ locale: options?.locale, per_page: 1 }))[0] ??
    null;
  if (!config?.homePage) return null;
  const raw = config.homePage;
  if (typeof raw === "string") return raw.trim() || null;
  const slug = (raw as { _slug?: string })?._slug?.trim();
  return slug ?? null;
}

/** Optionen für Content-Get (z. B. _resolve, _locale). */
export type ContentGetOptions = {
  locale?: string;
  /** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
  resolve?: string[];
};

/** Führenden Slash entfernen (CMS kann "slug": "/about" liefern). */
function normalizePageSlug(s: string | undefined): string {
  return (s ?? "").replace(/^\//, "").trim();
}

/**
 * Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
 * Die API erwartet oft _slug (z. B. "page-about"); die URL nutzt "about".
 * Versucht: slug, "/" + slug, dann Lookup in der Liste und Abruf per _slug.
 */
export async function getPageBySlug(
  slug: string,
  options?: ContentGetOptions,
): Promise<PageEntry | null> {
  const base = getBaseUrl();
  const trySlug = async (s: string): Promise<Response> => {
    const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
    if (options?.locale) u.searchParams.set("_locale", options.locale);
    if (options?.resolve?.length)
      u.searchParams.set("_resolve", options.resolve.join(","));
    return (await getCachedFetch())(u.toString());
  };
  let res = await trySlug(slug);
  if (res.status === 404 && !slug.startsWith("/")) {
    res = await trySlug("/" + slug);
  }
  // Dateiname im Content-Dir ist meist page-{urlSlug} (z. B. page-bi-vachdorf), nicht bi-vachdorf.
  if (res.status === 404 && !slug.startsWith("page-")) {
    res = await trySlug(`page-${slug}`);
  }
  if (res.status === 404) {
    const items = await getPages({
      locale: options?.locale,
    });
    const norm = normalizePageSlug(slug);
    const match = items.find(
      (p) =>
        normalizePageSlug(p.slug) === norm ||
        normalizePageSlug(p._slug) === norm ||
        p._slug === slug ||
        p.slug === slug,
    );
    if (match?._slug) {
      res = await trySlug(match._slug);
    }
  }
  if (res.status === 404) return null;
  if (!res.ok) {
    throw new Error(
      `RustyCMS get page failed: ${res.status} for slug "${slug}".`,
    );
  }
  return (await res.json()) as PageEntry;
}

/** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
export async function getPageSlugs(): Promise<string[]> {
  const items = await getPages();
  return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
}

/** Navigation (Single Source of Truth: RustyCMS OpenAPI). Nach Schema-Änderung: `yarn generate:api-types` mit laufendem RustyCMS. */
export type NavigationEntry = components["schemas"]["navigation"];

/** Ein Eintrag aus navigation.links (Referenz { _slug, _type } oder Slug-String). */
export type NavLinkEntry = NavigationEntry["links"] extends (infer L)[]
  ? L
  : never;

/** Antwort von GET /api/content/navigation (paginiert). */
export type NavigationListResponse =
  operations["listNavigation"]["responses"][200]["content"]["application/json"];

/**
 * Alle Navigation-Einträge (GET /api/content/navigation). Gecacht pro Prozess (Key aus options).
 */
export async function getNavigations(options?: {
  locale?: string;
  page?: number;
  per_page?: number;
  resolve?: string;
}): Promise<{
  items: NavigationEntry[];
  page: number;
  per_page: number;
  total: number;
  total_pages: number;
}> {
  const opts = options ?? {};
  const key = `getNavigations:${opts.locale ?? ""}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
  if (!skipListCache) {
    const p = listCache.get(key) as Promise<{
      items: NavigationEntry[];
      page: number;
      per_page: number;
      total: number;
      total_pages: number;
    }> | undefined;
    if (p) return p;
  }
  const p = (async () => {
    const base = getBaseUrl();
    const url = new URL(`${base}/api/content/navigation`);
    if (opts.locale) url.searchParams.set("_locale", opts.locale);
    if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
    url.searchParams.set("_page", String(opts.page ?? 1));
    url.searchParams.set("_per_page", String(opts.per_page ?? 50));
    const res = await (await getCachedFetch())(url.toString());
    if (!res.ok) {
      throw new Error(
        `RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
      );
    }
    const data = (await res.json()) as NavigationListResponse;
    return {
      items: data.items ?? [],
      page: data.page ?? 1,
      per_page: data.per_page ?? 50,
      total: data.total ?? 0,
      total_pages: data.total_pages ?? 1,
    };
  })();
  if (!skipListCache) listCache.set(key, p);
  return p;
}

/** Keys für Navigation wie bei windwiderstand (CF_Navigation_Keys). */
export const NavigationKeys = {
  header: "navigation-header",
  socialMedia: "navigation-social-media",
  footer: "navigation-footer",
} as const;

/**
 * Navigation anhand Key/Slug aus der Liste holen (wie getNavigationByKey in windwiderstand).
 * Mit options.resolve (z. B. "links") werden links aufgelöst (Link-Einträge mit url, linkName).
 */
export async function getNavigationByKey(
  key: string,
  options?: { locale?: string; resolve?: string },
): Promise<NavigationEntry | null> {
  const { items } = await getNavigations({
    locale: options?.locale ?? "de",
    per_page: 50,
    resolve: options?.resolve,
  });
  const entry = items.find(
    (n) =>
      (n as { _slug?: string; internal?: string })._slug === key ||
      (n as { _slug?: string; internal?: string }).internal === key,
  );
  return entry ?? null;
}

/**
 * Navigation anhand des Slugs holen (z. B. "header").
 * Zuerst Versuch über Listen-Endpunkt (getNavigationByKey), falls gewünscht nur Liste:
 * GET /api/content/navigation?_page=1&_per_page=50&_locale=de, dann Eintrag mit _slug/internal finden.
 * Optional: GET /api/content/navigation/:slug falls vom Backend unterstützt.
 */
export async function getNavigationBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<NavigationEntry | null> {
  const byKey = await getNavigationByKey(slug, options);
  if (byKey) return byKey;
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/navigation/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) return null;
  return (await res.json()) as NavigationEntry;
}

/** Footer (Single Source of Truth: RustyCMS OpenAPI). */
export type FooterEntry = components["schemas"]["footer"];

/**
 * Footer anhand des Slugs holen (z. B. "default").
 * content/de/footer/default.json5 → slug "default", locale "de".
 */
export async function getFooterBySlug(
  slug: string,
  options?: ContentGetOptions,
): Promise<FooterEntry | null> {
  const base = getBaseUrl();
  const url = new URL(`${base}/api/content/footer/${encodeURIComponent(slug)}`);
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  if (options?.resolve?.length)
    url.searchParams.set("_resolve", options.resolve.join(","));
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) {
    throw new Error(
      `RustyCMS get footer failed: ${res.status} for slug "${slug}".`,
    );
  }
  return (await res.json()) as FooterEntry;
}

/** Post-Eintrag (Single Source of Truth: RustyCMS OpenAPI). */
export type PostEntry = components["schemas"]["post"];

/**
 * Post anhand des Slugs holen (GET /api/content/post/:slug).
 */
/** Normalisiert Post-Slug für URL (führenden Slash entfernen). */
function normalizePostSlug(s: string | undefined): string {
  return (s ?? "").replace(/^\//, "").trim();
}

/**
 * Post nach Slug (GET /api/content/post/:slug).
 * Die API erwartet oft _slug; die URL nutzt post.slug. Bei 404: Liste laden und per _slug nachladen.
 */
export async function getPostBySlug(
  slug: string,
  options?: ContentGetOptions,
): Promise<PostEntry | null> {
  const base = getBaseUrl();
  const trySlug = async (s: string): Promise<Response> => {
    const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
    if (options?.locale) u.searchParams.set("_locale", options.locale);
    if (options?.resolve?.length)
      u.searchParams.set("_resolve", options.resolve.join(","));
    return (await getCachedFetch())(u.toString());
  };
  let res = await trySlug(slug);
  if (res.status === 404 && !slug.startsWith("/")) {
    res = await trySlug("/" + slug);
  }
  if (res.status === 404) {
    const items = await getPosts();
    const norm = normalizePostSlug(slug);
    const match = items.find(
      (p) =>
        normalizePostSlug(p.slug) === norm ||
        normalizePostSlug(p._slug) === norm ||
        p._slug === slug ||
        p.slug === slug,
    );
    if (match?._slug) {
      res = await trySlug(match._slug);
    }
  }
  if (res.status === 404) return null;
  if (!res.ok) {
    throw new Error(
      `RustyCMS get post failed: ${res.status} for slug "${slug}".`,
    );
  }
  return (await res.json()) as PostEntry;
}

/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
export async function getPostSlugs(): Promise<string[]> {
  const items = await getPosts();
  return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean);
}

/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve). */
export interface GetPostsOptions {
  _sort?: string;
  _order?: "asc" | "desc";
  /** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
  resolve?: string | string[];
}

/** Alle Post-Einträge (GET /api/content/post). Gecacht pro Prozess (Key aus options). */
export async function getPosts(
  options?: GetPostsOptions,
): Promise<PostEntry[]> {
  const opts = options ?? {};
  const resolveVal = Array.isArray(opts.resolve)
    ? opts.resolve.join(",")
    : (opts.resolve ?? "");
  const key = `getPosts:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
  if (!skipListCache) {
    const p = listCache.get(key) as Promise<PostEntry[]> | undefined;
    if (p) return p;
  }
  const p = (async () => {
    const base = getBaseUrl();
    const url = new URL(`${base}/api/content/post`);
    if (opts._sort) url.searchParams.set("_sort", opts._sort);
    if (opts._order) url.searchParams.set("_order", opts._order);
    if (resolveVal) url.searchParams.set("_resolve", resolveVal);
    const res = await (await getCachedFetch())(url.toString());
    if (!res.ok) throw new Error("RustyCMS list post failed");
    const data = (await res.json()) as { items?: PostEntry[] };
    return data.items ?? [];
  })();
  if (!skipListCache) listCache.set(key, p);
  return p;
}

/** Tag-Eintrag (z. B. für Blog-Filter). */
export type TagEntry = components["schemas"]["tag"];

/** Alle Tags (GET /api/content/tag). Gecacht pro Prozess. */
export async function getTags(): Promise<TagEntry[]> {
  const key = "getTags";
  if (!skipListCache) {
    const p = listCache.get(key) as Promise<TagEntry[]> | undefined;
    if (p) return p;
  }
  const p = (async () => {
    const base = getBaseUrl();
    const res = await (await getCachedFetch())(`${base}/api/content/tag`);
    if (!res.ok) return [];
    const data = (await res.json()) as { items?: TagEntry[] };
    return data.items ?? [];
  })();
  if (!skipListCache) listCache.set(key, p);
  return p;
}

/** Text-Fragment (z. B. für searchable_text). */
export type TextFragmentEntry = components["schemas"]["text_fragment"];

/** Text-Fragment anhand Slug (GET /api/content/text_fragment/:slug). */
export async function getTextFragmentBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<TextFragmentEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) {
    throw new Error(
      `RustyCMS get text_fragment failed: ${res.status} for slug "${slug}".`,
    );
  }
  return (await res.json()) as TextFragmentEntry;
}

/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];

/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
export async function getFullwidthBannerBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<FullwidthBannerEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) {
    throw new Error(
      `RustyCMS get fullwidth_banner failed: ${res.status} for slug "${slug}".`,
    );
  }
  return (await res.json()) as FullwidthBannerEntry;
}

/** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */
export type TranslationEntry = { _slug?: string; text: string };

/**
 * Translation anhand Slug (GET /api/content/translation/:slug).
 * Für zukünftiges Translation-Management: Texte aus dem CMS statt hardcoded.
 */
export async function getTranslationBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<TranslationEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/translation/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) {
    return null;
  }
  return (await res.json()) as TranslationEntry;
}

/** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */
export type TranslationBundleEntry = {
  _slug?: string;
  strings: Record<string, string>;
};

/**
 * Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
 * Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
 * Mit _resolve=all werden Referenzen in strings aufgelöst (z. B. blog_tag_all).
 */
export async function getTranslationBundleBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<TranslationBundleEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
  );
  url.searchParams.set("_resolve", "all");
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) {
    return null;
  }
  return (await res.json()) as TranslationBundleEntry;
}

/** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"];

/** Kalender-Item (calendar_item) – aus OpenAPI-Spec. */
export type CalendarItemEntry = components["schemas"]["calendar_item"];

/** Kalender anhand Slug (GET /api/content/calendar/:slug). */
export async function getCalendarBySlug(
  slug: string,
  options?: { locale?: string; resolve?: string },
): Promise<CalendarEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/calendar/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) return null;
  return (await res.json()) as CalendarEntry;
}

/** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */
export async function getCalendarItemBySlug(
  slug: string,
  options?: { locale?: string },
): Promise<CalendarItemEntry | null> {
  const base = getBaseUrl();
  const url = new URL(
    `${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
  );
  if (options?.locale) url.searchParams.set("_locale", options.locale);
  const res = await (await getCachedFetch())(url.toString());
  if (res.status === 404) return null;
  if (!res.ok) return null;
  return (await res.json()) as CalendarItemEntry;
}
