import { formatPrice } from "./format";

const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";

// Used only by the fetchers backing Live Preview (posts, legal pages,
// testimonials) — callers pass `draft: true` only while Draft Mode is
// enabled (a document open in the Payload admin's Live Preview iframe),
// bypassing the normal 60s ISR cache there without affecting ordinary
// visitors. Deliberately does NOT call next/headers' draftMode() itself
// here — this module's mapping functions/types (mapPayloadTestimonial,
// mapPayloadPost, etc.) are also imported by "use client" components
// (LiveTestimonialsGrid.tsx, LivePostContent.tsx), and next/headers is a
// server-only import that breaks the client bundle the moment anything
// in this file touches it, even indirectly.
function livePreviewCacheOption(draft: boolean): { cache: "no-store" } | { next: { revalidate: 60 } } {
  return draft ? { cache: "no-store" } : { next: { revalidate: 60 } };
}

export type BlogPost = {
  id: number;
  title: string;
  slug: string;
  categories: string[];
  readTime: number;
  excerpt: string;
  thumbnail: string | null;
  publishedAt: string;
  featured: boolean;
};

type PayloadPost = {
  id: number;
  title: string;
  slug: string;
  categories: ({ name: string } | number)[];
  readTime: number;
  excerpt: string;
  thumbnail: { url: string } | number | null;
  publishedAt: string;
  featured: boolean;
};

// sort: "-featured,-publishedAt" — Payload's multi-field sort puts any
// featured post(s) first, most-recently-published first among those, then
// the rest by date. Callers that just want posts[0] as "the" featured post
// (e.g. /blog) get a deterministic pick even if more than one post was
// accidentally marked featured — no special-casing needed here.
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    // Draft/scheduled posts never appear publicly — same "filter, not
    // access-control" pattern as Products.active. Live preview
    // (LivePostContent.tsx) bypasses this entirely since it fetches the
    // one specific document by id directly, not through this list.
    "where[status][equals]": "published",
    sort: "-featured,-publishedAt",
    depth: "2",
    limit: String(limit),
  });

  const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getBlogPosts: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadPost[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((post) => ({
    id: post.id,
    title: post.title,
    slug: post.slug,
    categories: (post.categories ?? [])
      .map((c) => (typeof c === "object" && c ? c.name : null))
      .filter((name): name is string => Boolean(name)),
    readTime: post.readTime,
    excerpt: post.excerpt,
    thumbnail:
      typeof post.thumbnail === "object" && post.thumbnail
        ? post.thumbnail.url
        : null,
    publishedAt: post.publishedAt,
    featured: post.featured,
  }));
}

export type PostDetail = BlogPost & {
  content: unknown;
  /** Label for every blockquote callout in this post's content (icon +
   * underline included) — empty string hides that left side entirely,
   * the blockquote itself still renders. See RichText.tsx's "quote" case. */
  quoteLabel: string;
  /** Powers the "Passend dazu" card at the end of the post — null hides
   * the card entirely, per-post choice (unlike Products.spotlight, which
   * is a single site-wide flag). */
  relatedProduct: Product | null;
  /** SEO overrides (Posts.ts's "SEO" collapsible group) — each null when
   * empty, callers fall back to title/excerpt/thumbnail themselves rather
   * than baking the fallback in here, so the distinction between "no
   * override set" and "override happens to equal the normal value" stays
   * visible to whoever reads this. */
  seoTitle: string | null;
  seoDescription: string | null;
  seoImage: string | null;
};

export type PayloadPostDetail = PayloadPost & {
  content: unknown;
  quoteLabel: string | null;
  relatedProduct: PayloadProduct | null;
  seoTitle?: string | null;
  seoDescription?: string | null;
  seoImage?: { url: string } | number | null;
};

// Shared by getPostBySlug() and LivePostContent.tsx (which re-maps the raw
// document useLivePreview receives via postMessage using this same logic,
// instead of duplicating the field mapping a second time).
export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
  return {
    id: doc.id,
    title: doc.title,
    slug: doc.slug,
    categories: (doc.categories ?? [])
      .map((c) => (typeof c === "object" && c ? c.name : null))
      .filter((name): name is string => Boolean(name)),
    readTime: doc.readTime,
    excerpt: doc.excerpt,
    thumbnail:
      typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
    content: doc.content,
    publishedAt: doc.publishedAt,
    featured: doc.featured,
    quoteLabel: doc.quoteLabel ?? "",
    relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
    seoTitle: doc.seoTitle || null,
    seoDescription: doc.seoDescription || null,
    seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
  };
}

export async function getPostBySlug(slug: string, options?: { draft?: boolean }): Promise<PostDetail | null> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[slug][equals]": slug,
    depth: "2",
    limit: "1",
  });
  // Draft/scheduled posts 404 for a normal visitor — draftMode's preview
  // (options.draft, wired from the page's own draftMode() call) is the
  // one legitimate way to view one before its scheduledPublishAt fires.
  if (!options?.draft) params.set("where[status][equals]", "published");

  const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
  if (!res.ok) {
    console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }

  const data: { docs?: PayloadPostDetail[] } = await res.json();
  const doc = data.docs?.[0];
  if (!doc) return null;
  return mapPayloadPost(doc);
}

// Frontend-facing shape — `id` is Payload's `slug` field, not its numeric
// row id. Cart items are stored in localStorage keyed by this string (see
// lib/cart.ts), so slugs were chosen in the Products collection to match
// the ids the old hardcoded catalog used ("todo-karten" etc.) — switching
// to numeric ids here would silently orphan every existing shopper's cart.
export type Product = {
  id: string;
  // The raw Payload numeric id — `id` above is the slug (used everywhere
  // as the "commerce id" — cart, checkout, URLs), but a few relationships
  // (Orders.items.product, WishlistItems.product) are real Payload
  // relationship fields storing this number instead. Kept alongside the
  // slug rather than replacing it, to avoid touching every existing
  // slug-based call site.
  numericId: number;
  name: string;
  // Short tagline shown under the name on cards/cart/PDP hero — see
  // Products.ts's own field comment. null/"" means show nothing extra.
  subline: string | null;
  // Raw Lexical richText JSON — rendered formatted (bold/italic/multiple
  // paragraphs) on the PDP via the shared <RichText> component. Plain-text
  // consumers (Passend-dazu cards, JSON-LD) use `descriptionText` below
  // instead of re-deriving text from this themselves.
  description: unknown | null;
  descriptionText: string;
  // Not shown anywhere in the UI today — only consumed by
  // buildProductSchema (JSON-LD's Product.sku), a real Schema.org property
  // Google's rich-result validator looks for.
  sku: string | null;
  price: number;
  compareAtPrice: number | null;
  image: string;
  // Extra photos beyond `image` above, for ProductGallery.tsx's
  // thumbnail-strip UI — empty for the vast majority of products (opt-in
  // per product, see Products.ts's own field comment). Never includes
  // `image` itself; ProductGallery treats `image` as always-slide-zero.
  gallery: string[];
  href: string | null;
  // `active` is opt-in for callers to filter by, not applied inside
  // getProducts()/getProductBySlug() themselves — cart, checkout, order
  // confirmation, and already-linked product detail pages (e.g.
  // TodoKartenHero/Pricing calling getProductBySlug directly) all need to
  // keep resolving a product regardless of its active status, unlike the
  // shop grid / spotlight / related-products discovery surfaces, which
  // filter `.filter(p => p.active)` themselves.
  active: boolean;
  updatedAt: string;
  spotlight: boolean;
  spotlightEyebrow: string | null;
  spotlightHeadline: string | null;
  // Lexical richText JSON (switched from plain textarea 2026-08-25 so
  // admins can bold parts of the teaser) — rendered by
  // ProductSpotlight.tsx's own minimal inline converter, not the full
  // article-oriented RichText.tsx (that one adds block-level heading/
  // paragraph spacing meant for blog posts/legal pages, wrong fit for a
  // single teaser paragraph).
  spotlightText: unknown | null;
  spotlightImage: string | null;
  // Per-product opt-in for a wishlist heart on the homepage spotlight —
  // independent of (in addition to) the global wishlistEnabled toggle,
  // which still gates the feature site-wide regardless of this flag.
  spotlightShowWishlist: boolean;
  // Plain booleans, not the raw stock/threshold numbers — the public API
  // has no reason to leak exact stock counts, callers only ever need
  // "can this be bought right now". `outOfStock` on the product itself
  // only matters for a product with no variants; a varianted product's
  // buyability is entirely per-variant (see each variant's own flag).
  outOfStock: boolean;
  // Derived, like outOfStock — no raw stock count/threshold leaked, callers
  // only ever need "should a low-stock hint show for this right now".
  lowStock: boolean;
  // Unlike outOfStock/lowStock, this DOES expose the real number — it's
  // the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton,
  // CartContent's quantity stepper) need client-side to stop a shopper from
  // putting more in the cart than checkout would actually accept, instead
  // of only finding out at the very last step (api/checkout/route.ts's own
  // stock check, which stays as the authoritative server-side guard). null
  // means "no cap" — backorder allowed or inventory not tracked.
  maxQty: number | null;
  // Per-product override — null means "use the tenant's default rate"
  // (CompanySettings.taxRatePercent, fetched separately since it's behind
  // an admin-only secret, see getCompanySettings()). Display-only on the
  // storefront; the actual rate used for order totals is resolved and
  // snapshotted server-side at checkout (api/checkout/route.ts).
  taxRatePercent: number | null;
  // No shipping cost for this product at all (e.g. a digital download) —
  // never shows "zzgl. Versand" on its own product page, and doesn't count
  // toward "does this cart need a shipping line" (lib/cartTotals.ts's
  // cartHasShippableItem()). A cart with even one item that does NOT have
  // this set still gets charged/shown the normal shipping cost — this only
  // exempts the individual product, not the whole cart.
  noShippingCost: boolean;
  variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
  // Empty for a product that predates this field or was never tagged —
  // treated as "matches every category filter" by the shop grid rather
  // than "matches none", since an uncategorized product shouldn't just
  // disappear the moment a category filter is applied.
  categories: string[];
  // Optional cross-sell pick (Products.relatedProduct) — powers a "Passt
  // dazu" card on the product's own detail page, same field/pattern as
  // Posts.relatedProduct. null means no card. Only one level deep (this
  // related product's own relatedProduct is never resolved/shown) — see
  // mapPayloadProduct's own comment.
  relatedProduct: Product | null;
  // Optional PDP page-builder content — see Products.ts's own `layout`
  // field comment. Empty for every existing hand-coded PDP (der-eine,
  // todo-cards, tasse-die-pause); a product's own page.tsx decides whether
  // to render this or its bespoke components, this type doesn't enforce
  // either.
  layout: ProductBlock[];
  // Fixed PDP-shell fields, not part of `layout` — always render the same
  // way regardless of block order, per the page-builder spec's "shell vs.
  // blocks" split. Max 3 enforced server-side (Products.usps maxRows).
  usps: { icon: string; text: string }[];
  // "new" is the only manual state — discount/out-of-stock/low-stock
  // badges are derived automatically elsewhere (discountPercent()/
  // outOfStock/lowStock above); see Products.ts's own field comment for
  // why those two aren't also manual options here.
  badge: "none" | "new";
};

type PayloadProduct = {
  id: number;
  name: string;
  subline: string | null;
  slug: string;
  layout: PayloadProductBlock[] | null;
  description: unknown | null;
  sku: string | null;
  price: number;
  compareAtPrice: number | null;
  image: { url: string } | number | null;
  gallery: ({ url: string } | number)[] | null;
  detailHref: string | null;
  active: boolean;
  updatedAt: string;
  spotlight: boolean;
  spotlightEyebrow: string | null;
  spotlightHeadline: string | null;
  spotlightText: unknown | null;
  spotlightImage: { url: string } | number | null;
  spotlightShowWishlist: boolean;
  trackInventory: boolean;
  stock: number | null;
  allowBackorder: boolean;
  lowStockThreshold: number | null;
  taxRatePercent: number | null;
  noShippingCost: boolean;
  variants:
    | {
        name: string;
        priceOverride: number | null;
        trackInventory: boolean;
        stock: number | null;
        allowBackorder: boolean;
        lowStockThreshold: number | null;
      }[]
    | null;
  categories: ({ name: string } | number)[] | null;
  relatedProduct: PayloadProduct | number | null;
  usps: { icon: string; text: string }[] | null;
  badge: "none" | "new" | null;
};

// A product/variant is only actually unbuyable when it opted into
// inventory tracking AND has zero stock AND backorders aren't allowed —
// the same three-condition check lib/inventory.ts's adjustStock() effectively
// mirrors from the other direction (it only ever touches stock when
// trackInventory is on in the first place).
function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackorder: boolean): boolean {
  return trackInventory && !allowBackorder && (stock ?? 0) <= 0;
}

// Below the threshold but not already out of stock — out-of-stock gets its
// own distinct "Ausverkauft" badge, a low-stock one on top of that would be
// redundant/contradictory.
function isLowStock(trackInventory: boolean, stock: number | null, threshold: number | null): boolean {
  return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
}

// null (no cap) whenever backorder is allowed or inventory isn't tracked —
// only a hard-tracked, non-backorderable stock count actually limits what a
// shopper can add to their cart.
function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null {
  return trackInventory && !allowBackorder ? (stock ?? 0) : null;
}

type LexicalTextNode = {
  text?: string;
  children?: LexicalTextNode[];
};

// Mirrors the backend's own Posts.ts extractPlainText (same shape, same
// space-joined fallback for multiple paragraphs/children) — kept as a
// separate copy rather than a shared package since it's a few lines and
// the two run in different runtimes (Payload server vs. this frontend).
function extractPlainText(node: LexicalTextNode | null | undefined): string {
  if (!node) return "";
  if (typeof node.text === "string") return node.text;
  if (Array.isArray(node.children)) return node.children.map(extractPlainText).join(" ");
  return "";
}

// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
// about eliminating elsewhere.
export function mapPayloadProduct(product: PayloadProduct): Product {
  const descriptionRoot = (product.description as { root?: LexicalTextNode } | null)?.root;
  return {
    id: product.slug,
    numericId: product.id,
    name: product.name,
    subline: product.subline || null,
    description: product.description ?? null,
    descriptionText: extractPlainText(descriptionRoot),
    sku: product.sku || null,
    price: product.price,
    compareAtPrice: product.compareAtPrice ?? null,
    image: typeof product.image === "object" && product.image ? product.image.url : "",
    gallery: (product.gallery ?? [])
      .map((g) => (typeof g === "object" && g ? g.url : null))
      .filter((url): url is string => Boolean(url)),
    href: product.detailHref || null,
    active: product.active,
    updatedAt: product.updatedAt,
    spotlight: product.spotlight,
    spotlightEyebrow: product.spotlightEyebrow || null,
    spotlightHeadline: product.spotlightHeadline || null,
    spotlightText: product.spotlightText || null,
    spotlightImage:
      typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
    spotlightShowWishlist: product.spotlightShowWishlist,
    outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
    lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
    maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
    taxRatePercent: product.taxRatePercent ?? null,
    noShippingCost: product.noShippingCost,
    variants: (product.variants ?? []).map((v) => ({
      name: v.name,
      priceOverride: v.priceOverride,
      outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
      lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
      maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
    })),
    categories: (product.categories ?? [])
      .map((c) => (typeof c === "object" && c ? c.name : null))
      .filter((name): name is string => Boolean(name)),
    // Recursive call is safe here — depth=2 on the originating fetch means
    // this nested object's OWN relatedProduct is never populated (stays a
    // raw id or absent), so the recursion bottoms out after exactly one level.
    relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
    layout: (product.layout ?? []).map(mapPayloadProductBlock),
    usps: product.usps ?? [],
    badge: product.badge ?? "none",
  };
}

export async function getProducts(): Promise<Product[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    sort: "sortOrder",
    depth: "2",
    limit: "100",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getProducts: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadProduct[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map(mapPayloadProduct);
}

export async function getProductBySlug(slug: string): Promise<Product | null> {
  const products = await getProducts();
  return products.find((p) => p.id === slug) ?? null;
}

// For account order pages — Orders.items only snapshots a numeric
// `product` relationship id (see CustomerOrderItem in lib/customerAuth.ts),
// not an image URL, unlike the checkout/email/invoice paths that resolve
// the image once at order-creation/send time. depth=1 + a single `in`
// query is a plain product-id → image-url lookup, deliberately separate
// from getProducts()'s slug-keyed catalog (an order can reference a
// product that's since been deactivated/deleted, and slugs aren't even
// the key an order item stores).
// Powers /konto/merkliste — WishlistItems.product is a real numeric
// relationship (see Product.numericId's own comment), so displaying the
// wishlist needs a numeric-id lookup rather than getProducts()'s
// slug-keyed list.
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
  const uniqueIds = [...new Set(ids)];
  if (uniqueIds.length === 0) return [];
  const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
  const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
  if (!res.ok) {
    console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }
  const data: { docs?: PayloadProduct[] } = await res.json();
  return (data.docs ?? []).map(mapPayloadProduct);
}

export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
  const uniqueIds = [...new Set(ids)];
  const map = new Map<number, string>();
  if (uniqueIds.length === 0) return map;
  const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "1", limit: String(uniqueIds.length) });
  const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
  if (!res.ok) return map;
  const data: { docs?: { id: number; image: { url: string } | number | null }[] } = await res.json();
  for (const doc of data.docs ?? []) {
    if (typeof doc.image === "object" && doc.image) map.set(doc.id, doc.image.url);
  }
  return map;
}

// Resolves a bare media id to its download URL — used by
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
// stays a raw id on the order fetch itself (that fetch is deliberately
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
// that fetch's depth just for this one occasional field. `no-store`, not
// ISR-cached like getProductImagesByIds — a return label is a one-off,
// account-specific document, not shared/reusable content worth caching.
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
  const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
  if (!res.ok) return null;
  const data: { url?: string; filename?: string } = await res.json();
  if (!data.url) return null;
  return { url: data.url, filename: data.filename ?? "download.pdf" };
}

// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
// array check instead of a second round-trip.
//
// Auto-spotlight: with exactly 1 active product, that product IS the
// spotlight, full stop — overriding any `spotlight` flag set on some
// other (inactive) product. Confirmed product decision, not just a
// no-manual-flag fallback. Otherwise, same deterministic tie-break as
// before (most-recently-updated wins) among active products actually
// flagged `spotlight`.
export async function getSpotlightProduct(): Promise<Product | null> {
  const products = await getProducts();
  const active = products.filter((p) => p.active);

  if (active.length === 1) return active[0];

  const flagged = active.filter((p) => p.spotlight);
  if (flagged.length === 0) return null;
  return flagged.reduce((latest, p) => (p.updatedAt > latest.updatedAt ? p : latest));
}

export type TrustBadge = { id: number; title: string; description: string; icon: string };

type PayloadTrustBadge = { id: number; title: string; description: string; icon: { url: string } | number | null };

async function fetchTrustBadgeList(collection: "trust-badges" | "cart-trust-badges"): Promise<TrustBadge[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    sort: "sortOrder",
    depth: "1",
    limit: "50",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/${collection}?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`fetchTrustBadgeList(${collection}): Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadTrustBadge[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((doc) => ({
    id: doc.id,
    title: doc.title,
    description: doc.description,
    icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
  }));
}

// Substitutes literal "{{lieferzeit}}" / "{{kostenfreiab}}" tokens (e.g. in
// a badge's "In {{lieferzeit}} bei dir." or "Ab {{kostenfreiab}}
// Bestellwert innerhalb DE." copy) with the live delivery-time range /
// free-shipping threshold — lets an editor reference these numbers from
// free text without duplicating and hand-maintaining them, which is
// exactly how the "Schneller Versand" and "Versandkostenfrei" badges used
// to drift from the real numbers whenever those changed elsewhere but not
// here too. The threshold comes from the lowest freeShippingThreshold
// among active ShippingMethods (same rule /cart's own banner uses), not
// lib/shipping.ts's separate FREE_SHIPPING_THRESHOLD constant — that
// constant is a third, independent copy of the same fact and not
// necessarily what actually governs checkout.
function resolveShippingTokens(text: string, shipping: ShippingSettings, freeShippingThreshold: number | null): string {
  let result = text.replaceAll("{{lieferzeit}}", `${shipping.totalDays.min}–${shipping.totalDays.max} Werktagen`);
  if (freeShippingThreshold !== null) {
    result = result.replaceAll("{{kostenfreiab}}", formatPrice(freeShippingThreshold));
  }
  return result;
}

// Powers TrustRow.tsx — the horizontal "Schneller Versand /
// Versandkostenfrei / Mit Liebe verpackt" row.
export async function getTrustBadges(): Promise<TrustBadge[]> {
  const [badges, shipping, shippingMethods] = await Promise.all([
    fetchTrustBadgeList("trust-badges"),
    getShippingSettings(),
    getShippingMethods(),
  ]);
  const thresholds = shippingMethods
    .map((m) => m.freeShippingThreshold)
    .filter((t): t is number => t !== null);
  const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;

  return badges.map((b) => ({
    ...b,
    title: resolveShippingTokens(b.title, shipping, freeShippingThreshold),
    description: resolveShippingTokens(b.description, shipping, freeShippingThreshold),
  }));
}

// Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig
// verpackt" sidebar bullets on /cart (title only) and /checkout
// (title + description) — a different list from TrustBadges.
export async function getCartTrustBadges(): Promise<TrustBadge[]> {
  return fetchTrustBadgeList("cart-trust-badges");
}

export type ShippingMethod = {
  id: number;
  title: string;
  description: string;
  price: number;
  freeShippingThreshold: number | null;
};

type PayloadShippingMethod = ShippingMethod & { active: boolean };

export async function getShippingMethods(): Promise<ShippingMethod[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[active][equals]": "true",
    sort: "sortOrder",
    limit: "20",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/shipping-methods?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getShippingMethods: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadShippingMethod[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((doc) => ({
    id: doc.id,
    title: doc.title,
    description: doc.description,
    price: doc.price,
    freeShippingThreshold: doc.freeShippingThreshold ?? null,
  }));
}

// Feeds /checkout's "Land" <select> (both the billing address and the
// optional shipping-address override) and its PLZ digit-count validation
// — previously a hardcoded array + PLZ_DIGITS map in CheckoutContent.tsx
// itself. Which countries are actually deliverable can now change without
// a code deploy (e.g. temporarily dropping Schweiz — no customs/export-
// invoice handling exists for it yet). Deliberately unrelated to VAT-
// exemption eligibility (lib/vatExemption.ts's isExemptionEligibleCountry(),
// still hardcoded to "Österreich") — that's a legal/tax-law question, not
// a shipping-logistics one, and stays in code on purpose.
export type ShippingCountry = {
  name: string;
  plzDigits: number;
};

type PayloadShippingCountry = ShippingCountry & { active: boolean };

export async function getShippingCountries(): Promise<ShippingCountry[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[active][equals]": "true",
    sort: "sortOrder",
    limit: "20",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/shipping-countries?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getShippingCountries: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadShippingCountry[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((doc) => ({
    name: doc.name,
    plzDigits: doc.plzDigits,
  }));
}

export type ShippingSettings = {
  handlingDays: { min: number; max: number };
  transitDays: { min: number; max: number };
  /** Derived here, not stored in Payload — a third independently-editable
   * copy of the same fact is exactly the drift this collection replaces. */
  totalDays: { min: number; max: number };
};

type PayloadShippingSettings = {
  handlingDaysMin: number;
  handlingDaysMax: number;
  transitDaysMin: number;
  transitDaysMax: number;
};

// Falls back to the site's long-standing real-world numbers (1–2 handling,
// 2–4 transit) if Payload has no row yet or the fetch fails — same values
// the old hardcoded HANDLING_DAYS/TRANSIT_DAYS_DE constants used, so
// nothing regresses before this collection gets seeded/edited.
const SHIPPING_SETTINGS_FALLBACK: ShippingSettings = {
  handlingDays: { min: 1, max: 2 },
  transitDays: { min: 2, max: 4 },
  totalDays: { min: 3, max: 6 },
};

export async function getShippingSettings(): Promise<ShippingSettings> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    limit: "1",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/shipping-settings?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getShippingSettings: Payload returned ${res.status} ${res.statusText}`);
    return SHIPPING_SETTINGS_FALLBACK;
  }

  const data: { docs?: PayloadShippingSettings[] } = await res.json();
  const doc = Array.isArray(data.docs) ? data.docs[0] : undefined;
  if (!doc) return SHIPPING_SETTINGS_FALLBACK;

  const handlingDays = { min: doc.handlingDaysMin, max: doc.handlingDaysMax };
  const transitDays = { min: doc.transitDaysMin, max: doc.transitDaysMax };
  return {
    handlingDays,
    transitDays,
    totalDays: { min: handlingDays.min + transitDays.min, max: handlingDays.max + transitDays.max },
  };
}

// `provider` drives the checkout branch in app/api/checkout/route.ts —
// 'manual' (Überweisung) keeps today's immediate-order behavior, 'stripe'
// (Kreditkarte/PayPal) routes through the payment-intent/webhook-gated
// flow. Defaults to 'manual' below for any row created before this field
// existed, matching the Payload field's own default.
export type PaymentMethod = { id: number; title: string; icons: string[]; provider: "manual" | "stripe" };

type PayloadPaymentMethod = {
  id: number;
  title: string;
  active: boolean;
  icons: { icon: { url: string } | number | null }[];
  provider?: "manual" | "stripe";
};

export async function getPaymentMethods(): Promise<PaymentMethod[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[active][equals]": "true",
    sort: "sortOrder",
    depth: "1",
    limit: "20",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/payment-methods?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getPaymentMethods: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadPaymentMethod[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((doc) => ({
    id: doc.id,
    title: doc.title,
    icons: (doc.icons ?? [])
      .map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
      .filter((url): url is string => Boolean(url)),
    provider: doc.provider ?? "manual",
  }));
}

export type CheckoutPaymentOption = PaymentMethod & { hint?: string };

// Kreditkarte and PayPal both resolve to `provider: 'stripe'` today, and
// both end up on the exact same Stripe PaymentIntent
// (`automatic_payment_methods: { enabled: true }` — Stripe's own
// recommended Payment Element pattern lets Stripe itself decide which
// eligible method to show, rather than the older per-method
// Checkout-Session split). Pre-selecting one of two identical-behind-the-
// scenes rows before the payment step is therefore no longer a real
// choice, just redundant friction — so this collapses every active
// `stripe` row into one "Online-Zahlung" option (representative id =
// the first such row's, since app/api/checkout/route.ts only branches on
// `provider`, never on which specific stripe row was picked) with a hint
// explaining that the actual instrument is chosen on the next screen.
// `manual` rows (Überweisung) pass through unchanged — one real gateway
// there, one option, nothing to collapse.
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
  const stripeMethods = methods.filter((m) => m.provider === "stripe");
  if (stripeMethods.length === 0) return methods;

  const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
  const online: CheckoutPaymentOption = {
    id: stripeMethods[0].id,
    title: "Online-Zahlung",
    icons: combinedIcons,
    provider: "stripe",
    hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
  };

  // Preserve `methods`' own order (already sortOrder-sorted by the fetch)
  // instead of hardcoding manual-first — a real bug: "Online-Zahlung" had
  // a lower sortOrder than "Überweisung (Vorkasse)" in the admin, but
  // this function always put manual rows first regardless, so the
  // checkout showed them in the wrong order. Splice the combined entry in
  // at the position of the *first* stripe row encountered, drop any
  // further stripe rows (already folded into `online`).
  const result: CheckoutPaymentOption[] = [];
  let onlineInserted = false;
  for (const m of methods) {
    if (m.provider === "stripe") {
      if (!onlineInserted) {
        result.push(online);
        onlineInserted = true;
      }
      continue;
    }
    result.push(m);
  }
  return result;
}

export type WerkzeugeCard = {
  id: number;
  title: string;
  description: string;
  icon: string;
  ctaLabel: string;
  ctaHref: string;
};

type PayloadWerkzeugeCard = {
  id: number;
  title: string;
  description: string;
  icon: { url: string } | number | null;
  ctaLabel: string;
  ctaHref: string;
};

export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    sort: "sortOrder",
    depth: "1",
    limit: "20",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/werkzeuge-cards?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getWerkzeugeCards: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadWerkzeugeCard[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map((doc) => ({
    id: doc.id,
    title: doc.title,
    description: doc.description,
    icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
    ctaLabel: doc.ctaLabel,
    ctaHref: doc.ctaHref,
  }));
}

export type TestimonialsPage = "todo-cards" | "newsletter" | "klarheits-check" | "der-eine" | "einfach-anfangen";

export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };

export type PayloadTestimonial = {
  id: number;
  quote: string;
  name: string;
  role: string | null;
  avatar: { url: string } | number | null;
};

// Shared by getTestimonials() and LiveTestimonialsGrid.tsx (re-maps the raw
// document useLivePreview receives via postMessage using this same logic).
export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
  return {
    id: doc.id,
    quote: doc.quote,
    name: doc.name,
    role: doc.role ?? "",
    avatar: typeof doc.avatar === "object" && doc.avatar ? doc.avatar.url : "",
  };
}

// Powers the identically-styled customer testimonial grids on /todo-cards,
// /newsletter, /7-tage-klarheits-check, and /der-eine (see TestimonialsGrid.tsx) —
// previously hardcoded arrays kept manually in sync. The single-quote
// "photo band" testimonials on /not-found and /bestellbestaetigung are a
// different shape (no avatar/role) and are not part of this collection.
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[page][equals]": page,
    sort: "sortOrder",
    depth: "1",
    limit: "20",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
  if (!res.ok) {
    console.error(`getTestimonials: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: PayloadTestimonial[] } = await res.json();
  const docs = Array.isArray(data.docs) ? data.docs : [];
  return docs.map(mapPayloadTestimonial);
}

export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";

export type LegalPage = {
  type: LegalPageType;
  title: string;
  content: unknown;
  // Only populated for pages that need a dynamically-rendered section
  // MID-document (currently just AGB's "Vertragspartner", sourced from
  // company-settings — see VertragspartnerBlock.tsx). null everywhere
  // else, including pages like Datenschutz whose one dynamic section sits
  // at the very start and so can just be prepended instead of split.
  contentPart2: unknown | null;
  attachment: { url: string; title: string } | null;
  updatedAt: string;
};

type PayloadLegalPage = {
  type: LegalPageType;
  title: string;
  content: unknown;
  contentPart2?: unknown;
  attachment: { url: string; title: string } | number | null;
  updatedAt: string;
};

export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[type][equals]": type,
    depth: "1",
    limit: "1",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
  if (!res.ok) {
    console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }

  const data: { docs?: PayloadLegalPage[] } = await res.json();
  const doc = data.docs?.[0];
  if (!doc) return null;
  return {
    type: doc.type,
    title: doc.title,
    content: doc.content,
    contentPart2: doc.contentPart2 ?? null,
    attachment:
      typeof doc.attachment === "object" && doc.attachment
        ? { url: doc.attachment.url, title: doc.attachment.title }
        : null,
    updatedAt: doc.updatedAt,
  };
}

export type EmailTemplateType =
  | "order-confirmation"
  | "password-reset"
  | "order-shipped"
  | "order-cancelled"
  | "order-return-requested"
  | "order-returned"
  | "order-tracking-added"
  | "order-tracking-corrected"
  | "order-delivered"
  | "payment-method-switched"
  | "back-in-stock";

type PayloadEmailTemplate = {
  type: EmailTemplateType;
  subject: string;
  heading: string;
  bodyText: string;
  footerText: string | null;
  // false means the admin deliberately suppressed this email — checked
  // by orderEmail.ts before sending order-confirmation, never treated as
  // "row missing, use hardcoded default" (that's what a null return from
  // this function itself already means).
  active: boolean;
};

// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
// see EmailTemplates.ts in the Payload repo); the real send (orderEmail.ts,
// Customers.ts's forgotPassword hook) always reads the published version.
export async function getEmailTemplate(
  type: EmailTemplateType,
  options?: { draft?: boolean },
): Promise<PayloadEmailTemplate | null> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[type][equals]": type,
    limit: "1",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/email-templates?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
  if (!res.ok) {
    console.error(`getEmailTemplate: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }

  const data: { docs?: PayloadEmailTemplate[] } = await res.json();
  return data.docs?.[0] ?? null;
}

export type CompanySettings = {
  sellerName: string;
  // Drives whether registerCourt/registerNumber/managingDirector/
  // shareCapital are populated — mirrors Payload's CompanySettings.ts
  // collection exactly (same option values), see buildLegalFooterLines()
  // in emailTemplates.ts.
  legalForm: "sole-proprietorship" | "e-k" | "gbr" | "ohg" | "kg" | "gmbh" | "ug" | "ag";
  registerCourt: string | null;
  registerNumber: string | null;
  managingDirector: string | null;
  // Stammkapital (GmbH/UG) / Grundkapital (AG) — optional, NOT a
  // Pflichtangabe (only shown for legal forms that have this concept at
  // all; see CompanySettings.ts's SHARE_CAPITAL_APPLICABLE_FORMS and its
  // own comment on why this is voluntary, not required, disclosure).
  shareCapital: number | null;
  sellerStreet: string;
  sellerZip: string;
  sellerCity: string;
  sellerCountry: string;
  sellerEmail: string;
  // Admin-editable override for outgoing mail's "Von"-Feld (company-settings
  // "Adresse & Kontakt" tab, added 2026-07-29 alongside the SPF/SMTP-account
  // switch to einfach-produktiv.com) — falls back to sellerName/sellerEmail
  // when empty, see orderEmail.ts/alertAdmin.ts's own comments.
  emailFromName: string | null;
  emailFromAddress: string | null;
  vatId: string;
  taxRatePercent: number;
  // Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
  // order's items to 0% VAT (never de-grossed, unlike the intra-community
  // exemption) and the tax rate above is ignored. Read live only at
  // checkout time (see api/checkout/route.ts) to decide what to snapshot
  // onto the new order — never read live when rendering an existing
  // order's invoice, see OrderSnapshot/CustomerOrderDetail's own
  // `kleinunternehmer` field for why.
  kleinunternehmer: boolean;
  iban: string | null;
  bic: string | null;
  bankName: string | null;
};

// Server-only in practice (only ever called from app/lib/invoiceData.ts),
// but kept in this file rather than a "use server"-only module since every
// other Payload fetcher lives here too — no client component imports it.
// company-settings' read access is admin-only plus this same service
// secret (see CompanySettings.ts) — it holds bank details, not something
// to leave publicly readable like Products/ShippingSettings.
export async function getCompanySettings(): Promise<CompanySettings | null> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    cache: "no-store",
  });
  if (!res.ok) {
    console.error(`getCompanySettings: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }
  const data: { docs?: CompanySettings[] } = await res.json();
  return data.docs?.[0] ?? null;
}

// A separate, ISR-cached fetch (unlike getCompanySettings()'s deliberate
// cache: "no-store", where invoice generation needs always-fresh bank
// details/legal footer text) — the storefront's "inkl. X% MwSt." display
// rate only needs the same 60s freshness every other public catalog fetch
// here already has, and only ever needs the one number, not the seller's
// bank details/register info.
export async function getDefaultTaxRatePercent(): Promise<number> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getDefaultTaxRatePercent: Payload returned ${res.status} ${res.statusText}`);
    return 19;
  }
  const data: { docs?: { taxRatePercent: number }[] } = await res.json();
  return data.docs?.[0]?.taxRatePercent ?? 19;
}

// Same ISR-cached, public-catalog-freshness fetch as getDefaultTaxRatePercent()
// above (a separate round trip rather than reusing getCompanySettings()'s
// deliberate cache: "no-store") — powers the "inkl. X% MwSt." storefront
// hints (dropped entirely when this is true, see ProductGrid.tsx/
// ProductSpotlight.tsx/etc.) and the cart/checkout VAT-breakdown display.
export async function getKleinunternehmer(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getKleinunternehmer: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { kleinunternehmer: boolean }[] } = await res.json();
  return data.docs?.[0]?.kleinunternehmer ?? false;
}

// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
// the Navbar link) site-wide. Deliberately off by default (see
// CompanySettings.ts's own field comment) so the feature stays entirely
// invisible in the frontend until a tenant actually wants it, rather than
// shipping a half-finished-looking icon everywhere.
export async function getWishlistEnabled(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
  return data.docs?.[0]?.wishlistEnabled ?? false;
}

// Same pattern as getWishlistEnabled() — gates the Navbar's search icon
// (SearchOverlay.tsx). Off by default so the feature stays invisible
// until a tenant explicitly wants it.
export async function getSearchEnabled(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getSearchEnabled: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { searchEnabled?: boolean }[] } = await res.json();
  return data.docs?.[0]?.searchEnabled ?? false;
}

// Same pattern as getWishlistEnabled()/getSearchEnabled() — gates the
// shop overview's price-range filter (ProductGrid.tsx/PriceRangeFilter.tsx).
export async function getShopFilterEnabled(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getShopFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { shopFilterEnabled?: boolean }[] } = await res.json();
  return data.docs?.[0]?.shopFilterEnabled ?? false;
}

// Same pattern — gates the blog overview's category filter (blog/page.tsx).
export async function getBlogFilterEnabled(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getBlogFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { blogFilterEnabled?: boolean }[] } = await res.json();
  return data.docs?.[0]?.blogFilterEnabled ?? false;
}

// Same pattern — gates /konto/bestellungen's status/paymentStatus/year filters.
export async function getOrderFilterEnabled(): Promise<boolean> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getOrderFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
    return false;
  }
  const data: { docs?: { orderFilterEnabled?: boolean }[] } = await res.json();
  return data.docs?.[0]?.orderFilterEnabled ?? false;
}

export type SeoSettings = {
  defaultTitle: string | null;
  titleTemplate: string | null;
  defaultDescription: string | null;
  defaultOgImage: string | null;
  googleSearchConsoleVerification: string | null;
};

// Fallback matches the values hardcoded in app/layout.tsx before this field
// existed — used whenever the backend field is empty or unreachable, so
// filling in the CompanySettings SEO tab is optional, not a hard
// dependency for the site to render sensible metadata.
const SEO_SETTINGS_FALLBACK: SeoSettings = {
  defaultTitle: "einfach produktiv. – Weil du auch noch ein Leben hast",
  titleTemplate: "%s | einfach produktiv.",
  defaultDescription:
    "Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag – weil du auch noch ein Leben hast.",
  defaultOgImage: "/og-image.png",
  googleSearchConsoleVerification: null,
};

// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
// above — every page's metadata reads this, so it needs to be cheap/cached,
// not the always-fresh getCompanySettings() used for invoice generation.
export async function getSeoSettings(): Promise<SeoSettings> {
  const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1", depth: "1" });
  const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
    headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getSeoSettings: Payload returned ${res.status} ${res.statusText}`);
    return SEO_SETTINGS_FALLBACK;
  }
  const data: {
    docs?: {
      seoDefaultTitle?: string | null;
      seoTitleTemplate?: string | null;
      seoDefaultDescription?: string | null;
      seoDefaultOgImage?: { url?: string } | number | null;
      googleSearchConsoleVerification?: string | null;
    }[];
  } = await res.json();
  const doc = data.docs?.[0];
  if (!doc) return SEO_SETTINGS_FALLBACK;
  return {
    defaultTitle: doc.seoDefaultTitle || SEO_SETTINGS_FALLBACK.defaultTitle,
    titleTemplate: doc.seoTitleTemplate || SEO_SETTINGS_FALLBACK.titleTemplate,
    defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
    defaultOgImage:
      (typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
    googleSearchConsoleVerification:
      doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification,
  };
}

// Powers app/r/[code]/route.ts and app/sticker/[code]/route.ts — a static
// short link (e.g. printed on a QR code) that redirects to a `targetPath`
// editable in Payload at any time, so the QR code itself never needs
// reprinting. `cache: "no-store"` (unlike this file's other public-catalog
// fetches) since a stale hit here would send a visitor to a since-changed
// target, and the PATCH below needs the just-fetched id/clickCount, not a
// 60s-old ISR snapshot.
type PayloadRedirect = { id: number; targetPath: string; clickCount: number };

// `urlPrefix` filters the match to whichever fixed frontend route is
// actually calling this — Redirects.urlPrefix is otherwise just an admin
// label, not something either route enforced, so a code edited to
// urlPrefix "/sticker" in Payload would silently keep resolving under
// /r/<code> too without this. Each call site passes its own literal
// prefix (see route.ts files), so this actually makes the field mean
// something rather than only decorate the admin list.
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[code][equals]": code,
    "where[urlPrefix][equals]": urlPrefix,
    "where[active][equals]": "true",
    limit: "1",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/redirects?${params}`, { cache: "no-store" });
  if (!res.ok) {
    console.error(`resolveAndTrackRedirect: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }

  const data: { docs?: PayloadRedirect[] } = await res.json();
  const doc = data.docs?.[0];
  if (!doc) return null;

  fetch(`${PAYLOAD_URL}/api/redirects/${doc.id}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "",
    },
    body: JSON.stringify({ clickCount: doc.clickCount + 1, lastClickedAt: new Date().toISOString() }),
  }).catch((err) => console.error("resolveAndTrackRedirect: click-tracking PATCH failed", err));

  return doc.targetPath;
}

export type TrackingCode = {
  id: number;
  provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "google-maps" | "other";
  consentCategory: "necessary" | "analytics" | "marketing" | "content";
  measurementId: string | null;
  pixelId: string | null;
  containerId: string | null;
  customScript: string | null;
};

// Public read (TrackingCodes.ts's own access.read: () => true), unlike
// most of this file's other Company-Settings-adjacent fetchers — no
// x-order-service-secret header needed. Only `active: true` rows, since
// TrackingScripts.tsx has no reason to even know an inactive one exists.
// Consent-gating itself happens in TrackingScripts.tsx, not here — this
// fetcher runs server-side (no cookie access), the gating decision is a
// client-only concern (useConsent()).
export async function getTrackingCodes(): Promise<TrackingCode[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[active][equals]": "true",
    depth: "0",
    limit: "50",
  });
  const res = await fetch(`${PAYLOAD_URL}/api/tracking-codes?${params}`, {
    next: { revalidate: 60 },
  });
  if (!res.ok) {
    console.error(`getTrackingCodes: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }
  const data: { docs?: TrackingCode[] } = await res.json();
  return Array.isArray(data.docs) ? data.docs : [];
}

// ── Products.layout (PDP page builder) ─────────────────────────────────────
// Shares 9 of PageBlock's own block types verbatim (richTextSection/
// stepRow/quote/image/icon/pillList/checklistImage/table/ctaCard — same
// Payload Block configs, reused a second time on Products.layout, see that
// field's own comment) plus two product-specific ones that don't exist on
// Pages: productHero (the one PDP hero variant for now — just a body
// richText, everything else about the hero comes from the product itself)
// and productPricingPanel (the closing buy panel, no fields of its own).

export type ProductBlock =
  | { blockType: "productHero"; id: string; body: unknown | null }
  | { blockType: "productPricingPanel"; id: string }
  | { blockType: "richTextSection"; id: string; content: unknown }
  | { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
  | { blockType: "quote"; id: string; text: string; label: string | null }
  | { blockType: "image"; id: string; image: string | null; caption: string | null }
  | { blockType: "icon"; id: string; icon: string }
  | { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
  | { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
  | { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
  | { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
  | { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
  | {
      blockType: "storySplit";
      id: string;
      image: string | null;
      imagePosition: "left" | "right";
      heading: string | null;
      content: unknown;
      bullets: { id: string; text: string }[];
    }
  | { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
  // Not resolved to full Product objects here — same reasoning as
  // testimonialsRef not resolving its testimonials in this mapper: needs
  // its own async fetch (automatic mode queries by category; manual mode
  // needs getProductsByIds), done in ProductBlocks.tsx's own outer
  // Promise.all, mirroring how that file already special-cases
  // testimonialsRef for the exact same reason.
  | { blockType: "crossSell"; id: string; mode: "automatic" | "manual"; productIds: number[] };

type PayloadProductBlock =
  | { blockType: "productHero"; id: string; body?: unknown | null }
  | { blockType: "productPricingPanel"; id: string }
  | { blockType: "richTextSection"; id: string; content: unknown }
  | { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
  | { blockType: "quote"; id: string; text: string; label?: string | null }
  | { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
  | { blockType: "icon"; id: string; icon: string }
  | { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
  | { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
  | { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
  | { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
  | { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
  | {
      blockType: "storySplit";
      id: string;
      image: PayloadPageImageField;
      imagePosition?: "left" | "right" | null;
      heading?: string | null;
      content?: unknown;
      bullets?: { id: string; text: string }[];
    }
  | { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] }
  | { blockType: "crossSell"; id: string; mode?: "automatic" | "manual" | null; products?: (number | { id: number })[] | null };

function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
  switch (block.blockType) {
    case "productHero":
      return { blockType: "productHero", id: block.id, body: block.body ?? null };
    case "image":
      return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
    case "checklistImage":
      return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
    case "quote":
      return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
    case "ctaCard":
      return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
    case "stepRow":
      return {
        blockType: "stepRow",
        id: block.id,
        items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
      };
    case "storySplit":
      return {
        blockType: "storySplit",
        id: block.id,
        image: mapPayloadPageImage(block.image),
        imagePosition: block.imagePosition ?? "left",
        heading: block.heading ?? null,
        content: block.content ?? null,
        bullets: block.bullets ?? [],
      };
    case "faq":
      return { blockType: "faq", id: block.id, items: block.items };
    case "crossSell":
      return {
        blockType: "crossSell",
        id: block.id,
        mode: block.mode ?? "automatic",
        productIds: (block.products ?? []).map((p) => (typeof p === "object" ? p.id : p)),
      };
    default:
      return block;
  }
}

// ── Pages (generic slug-routed content pages / page builder) ──────────────
// Powers app/[slug]/page.tsx — the first generic catch-all route in this
// repo (every other content page today is its own static directory).
// `layout` is a Payload native `type: 'blocks'` field (docker/payload/src/
// collections/Pages.ts), a different shape from Posts.content's Lexical-
// embedded Blocks (see RichText.tsx's own comment) — each block here is a
// full page section, not an inline prose element.

export type PageBlock =
  | { blockType: "hero"; id: string; headline: string; subline: string | null; image: string | null; ctaLabel: string | null; ctaHref: string | null }
  | { blockType: "richTextSection"; id: string; content: unknown }
  | { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
  | { blockType: "quote"; id: string; text: string; label: string | null }
  | { blockType: "image"; id: string; image: string | null; caption: string | null }
  | { blockType: "icon"; id: string; icon: string }
  | { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
  | { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
  | { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
  | { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
  | { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string }
  | {
      blockType: "storySplit";
      id: string;
      image: string | null;
      imagePosition: "left" | "right";
      heading: string | null;
      content: unknown;
      bullets: { id: string; text: string }[];
    }
  | { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };

export type Page = {
  id: number;
  title: string;
  slug: string;
  layout: PageBlock[];
  seoTitle: string | null;
  seoDescription: string | null;
  seoImage: string | null;
};

type PayloadPageImageField = { url?: string | null } | number | null;

type PayloadPageBlock =
  | { blockType: "hero"; id: string; headline: string; subline?: string | null; image?: PayloadPageImageField; ctaLabel?: string | null; ctaHref?: string | null }
  | { blockType: "richTextSection"; id: string; content: unknown }
  | { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
  | { blockType: "quote"; id: string; text: string; label?: string | null }
  | { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
  | { blockType: "icon"; id: string; icon: string }
  | { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
  | { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
  | { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
  | { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
  | { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string }
  | {
      blockType: "storySplit";
      id: string;
      image: PayloadPageImageField;
      imagePosition?: "left" | "right" | null;
      heading?: string | null;
      content?: unknown;
      bullets?: { id: string; text: string }[];
    }
  | { blockType: "faq"; id: string; items: { id: string; question: string; answer: unknown }[] };

export type PayloadPage = {
  id: number;
  title: string;
  slug: string;
  layout: PayloadPageBlock[];
  seoTitle?: string | null;
  seoDescription?: string | null;
  seoImage?: { url: string } | number | null;
};

function mapPayloadPageImage(ref: PayloadPageImageField): string | null {
  return typeof ref === "object" && ref && typeof ref.url === "string" ? ref.url : null;
}

function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
  switch (block.blockType) {
    case "image":
      return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
    case "checklistImage":
      return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
    case "quote":
      return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
    case "ctaCard":
      return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
    case "stepRow":
      return {
        blockType: "stepRow",
        id: block.id,
        items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
      };
    case "hero":
      return {
        blockType: "hero",
        id: block.id,
        headline: block.headline,
        subline: block.subline ?? null,
        image: mapPayloadPageImage(block.image ?? null),
        ctaLabel: block.ctaLabel ?? null,
        ctaHref: block.ctaHref ?? null,
      };
    case "storySplit":
      return {
        blockType: "storySplit",
        id: block.id,
        image: mapPayloadPageImage(block.image),
        imagePosition: block.imagePosition ?? "left",
        heading: block.heading ?? null,
        content: block.content ?? null,
        bullets: block.bullets ?? [],
      };
    case "faq":
      return { blockType: "faq", id: block.id, items: block.items };
    default:
      return block;
  }
}

// Shared by getPageBySlug() and LivePageContent.tsx (re-maps the raw
// document useLivePreview receives via postMessage using this same logic)
// — same reuse pattern as mapPayloadPost.
export function mapPayloadPage(doc: PayloadPage): Page {
  return {
    id: doc.id,
    title: doc.title,
    slug: doc.slug,
    layout: (doc.layout ?? []).map(mapPayloadPageBlock),
    seoTitle: doc.seoTitle || null,
    seoDescription: doc.seoDescription || null,
    seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
  };
}

export async function getPageBySlug(slug: string, options?: { draft?: boolean }): Promise<Page | null> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[slug][equals]": slug,
    depth: "2",
    limit: "1",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
  if (!res.ok) {
    console.error(`getPageBySlug: Payload returned ${res.status} ${res.statusText}`);
    return null;
  }

  const data: { docs?: PayloadPage[] } = await res.json();
  const doc = data.docs?.[0];
  if (!doc) return null;
  return mapPayloadPage(doc);
}

export type SitemapEntry = { slug: string; updatedAt: string };

// Backs app/sitemap.ts. Pages has no status/draft field (see Pages.ts) —
// every doc that exists is live — so unlike getBlogPosts there's no
// `where[status]` filter needed here.
export async function getAllPageSlugs(): Promise<SitemapEntry[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    depth: "0",
    limit: "100",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, { next: { revalidate: 3600 } });
  if (!res.ok) {
    console.error(`getAllPageSlugs: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
  return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
}

// Same shape as getAllPageSlugs but for Posts, which — unlike Pages — does
// have a draft/scheduled state, so this filters to `published` the same
// way getBlogPosts does (the sitemap must never list a post that 404s).
export async function getAllPostSlugs(): Promise<SitemapEntry[]> {
  const params = new URLSearchParams({
    "where[tenant.slug][equals]": TENANT_SLUG,
    "where[status][equals]": "published",
    depth: "0",
    limit: "100",
  });

  const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, { next: { revalidate: 3600 } });
  if (!res.ok) {
    console.error(`getAllPostSlugs: Payload returned ${res.status} ${res.statusText}`);
    return [];
  }

  const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json();
  return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt }));
}
