/**
 * ============================================
 * SHOPWARE ADMIN API CLIENT  (server-only)
 * ============================================
 * The Store API has no generic "give me all snippets" endpoint, so UI
 * translation strings (snippets) are pulled from the Admin API instead.
 *
 * This module must NEVER be imported into a client component — it uses the
 * admin integration client id/secret. It is consumed only by the
 * /api/snippets route handler, which proxies a safe, public-readable subset
 * (the merged storefront snippet set for one locale) to the browser.
 *
 * Required env (see .env.example):
 *   SHOPWARE_ADMIN_CLIENT_ID      integration "Access key ID"
 *   SHOPWARE_ADMIN_CLIENT_SECRET  integration "Secret access key"
 */

const SHOPWARE_URL = process.env.NEXT_PUBLIC_SHOPWARE_URL || '';
const ACCESS_KEY = process.env.NEXT_PUBLIC_SHOPWARE_ACCESS_KEY || '';
const CLIENT_ID = process.env.SHOPWARE_ADMIN_CLIENT_ID || '';
const CLIENT_SECRET = process.env.SHOPWARE_ADMIN_CLIENT_SECRET || '';

export function isAdminApiConfigured(): boolean {
  return Boolean(SHOPWARE_URL && CLIENT_ID && CLIENT_SECRET);
}

// ─── OAuth token (client_credentials), cached until shortly before expiry ────
let tokenCache: { token: string; expiresAt: number } | null = null;

async function getAdminToken(): Promise<string> {
  const now = Date.now();
  if (tokenCache && now < tokenCache.expiresAt) {
    return tokenCache.token;
  }
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
    cache: 'no-store',
  });
  if (!res.ok) {
    throw new Error(`Admin OAuth failed: ${res.status}`);
  }
  const data: { access_token: string; expires_in: number } = await res.json();
  // Refresh 60s before the real expiry to avoid edge-of-expiry 401s.
  tokenCache = {
    token: data.access_token,
    expiresAt: now + (data.expires_in - 60) * 1000,
  };
  return data.access_token;
}

// ─── Snippet sets: iso (e.g. "de-DE") → snippetSetId ─────────────────────────
// Only used as a fallback when domain-based lookup can't find the URL.
let snippetSetIdCache: Record<string, string> = {};

async function getSnippetSetIdByIso(iso: string): Promise<string | null> {
  if (snippetSetIdCache[iso]) return snippetSetIdCache[iso];
  const token = await getAdminToken();
  const res = await fetch(`${SHOPWARE_URL}/api/search/snippet-set`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      filter: [{ type: 'equals', field: 'iso', value: iso }],
      limit: 1,
    }),
    cache: 'no-store',
  });
  if (!res.ok) return null;
  const data: { data?: Array<{ id: string }> } = await res.json();
  const id = data.data?.[0]?.id ?? null;
  if (id) snippetSetIdCache[iso] = id;
  return id;
}

// ─── Sales-channel domains (URL → snippet-set + language + currency) ────────
// This is the authoritative mapping configured in Admin → Sales Channels →
// (channel) → Domains. The storefront uses it so the snippet set you assign
// to a domain is what renders for that URL.

export interface SalesChannelDomain {
  id: string;
  url: string;
  snippetSetId: string;
  languageId: string;
  currencyId: string;
}

let domainsCache: { domains: SalesChannelDomain[]; at: number } | null = null;
// Short TTL so removing/adding a sales-channel domain in admin reflects on the
// storefront (e.g. the language switcher) within a minute — no restart needed.
const DOMAINS_TTL_MS = 60 * 1000;

export async function getSalesChannelDomains(): Promise<SalesChannelDomain[]> {
  if (domainsCache && Date.now() - domainsCache.at < DOMAINS_TTL_MS) {
    return domainsCache.domains;
  }
  if (!isAdminApiConfigured() || !ACCESS_KEY) return domainsCache?.domains ?? [];
  try {
    const token = await getAdminToken();
    const res = await fetch(`${SHOPWARE_URL}/api/search/sales-channel-domain`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({
        // Restrict to domains of OUR headless sales channel via its access key.
        filter: [{ type: 'equals', field: 'salesChannel.accessKey', value: ACCESS_KEY }],
        limit: 50,
      }),
      cache: 'no-store',
    });
    if (!res.ok) return domainsCache?.domains ?? [];
    const data: { data?: Array<any> } = await res.json();
    // Admin API returns JSON:API — entity fields live under `attributes`.
    const domains: SalesChannelDomain[] = (data.data || []).map((d) => ({
      id: d.id,
      url: d.attributes?.url ?? d.url,
      snippetSetId: d.attributes?.snippetSetId ?? d.snippetSetId,
      languageId: d.attributes?.languageId ?? d.languageId,
      currencyId: d.attributes?.currencyId ?? d.currencyId,
    }));
    domainsCache = { domains, at: Date.now() };
    return domains;
  } catch {
    return domainsCache?.domains ?? [];
  }
}

function normaliseUrl(u: string): string {
  return u.replace(/\/+$/, '').toLowerCase();
}

/**
 * Find the sales-channel domain whose URL matches the current request's
 * origin (protocol + host). Returns null if no domain matches — caller
 * should fall back to iso-based lookup.
 */
export async function findDomainForOrigin(
  origin: string
): Promise<SalesChannelDomain | null> {
  const domains = await getSalesChannelDomains();
  const want = normaliseUrl(origin);
  // Exact match first (host:port + protocol).
  const exact = domains.find((d) => normaliseUrl(d.url) === want);
  if (exact) return exact;
  // Host-only match (ignore protocol mismatch from x-forwarded-proto quirks).
  try {
    const wantHost = new URL(want).host;
    const byHost = domains.find((d) => {
      try {
        return new URL(d.url).host === wantHost;
      } catch {
        return false;
      }
    });
    if (byHost) return byHost;
  } catch {
    // origin wasn't a valid URL — give up
  }
  return null;
}

// ─── Language metadata (languageId → name + locale), via public Store API ────
// The Store API /language endpoint is public (sw-access-key) and returns every
// language assigned to the channel with its locale code. We use it only to put
// human names + locale codes onto the domain rows below.
interface LanguageMeta {
  name: string;
  localeCode: string;
}
let languageMetaCache: { meta: Record<string, LanguageMeta>; at: number } | null = null;
const LANG_META_TTL_MS = 60 * 1000;

async function getLanguageMeta(): Promise<Record<string, LanguageMeta>> {
  if (languageMetaCache && Date.now() - languageMetaCache.at < LANG_META_TTL_MS) {
    return languageMetaCache.meta;
  }
  if (!SHOPWARE_URL || !ACCESS_KEY) return languageMetaCache?.meta ?? {};
  try {
    const res = await fetch(`${SHOPWARE_URL}/store-api/language`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'sw-access-key': ACCESS_KEY },
      body: JSON.stringify({ associations: { locale: {} }, limit: 50 }),
      cache: 'no-store',
    });
    if (!res.ok) return languageMetaCache?.meta ?? {};
    const data: {
      elements?: Array<{ id: string; name?: string; translated?: { name?: string }; locale?: { code?: string } }>;
    } = await res.json();
    const meta: Record<string, LanguageMeta> = {};
    for (const l of data.elements || []) {
      if (!l.id || !l.locale?.code) continue;
      meta[l.id] = { name: l.translated?.name || l.name || l.locale.code, localeCode: l.locale.code };
    }
    languageMetaCache = { meta, at: Date.now() };
    return meta;
  } catch {
    return languageMetaCache?.meta ?? {};
  }
}

// ─── Storefront languages, derived from the channel's DOMAINS (not its
// assigned-languages list). A language only appears here if an admin created a
// domain for it — that's what drives the frontend language switcher. ──────────
export interface StorefrontLanguage {
  languageId: string;
  name: string; // e.g. "Deutsch"
  localeCode: string; // e.g. "de-DE"
  url: string; // full domain URL, e.g. "http://localhost/de-DE"
  currencyId: string; // the currency the admin assigned to this domain
}

export async function getStorefrontLanguages(
  preferProtocol?: 'http' | 'https'
): Promise<StorefrontLanguage[]> {
  const [domains, meta] = await Promise.all([getSalesChannelDomains(), getLanguageMeta()]);
  // Group domains by language so http/https (or path) variants collapse to one
  // entry. When a language has several domains, prefer the one whose protocol
  // matches the current request so the switch doesn't bounce http↔https.
  const byLang = new Map<string, SalesChannelDomain[]>();
  for (const d of domains) {
    if (!meta[d.languageId]) continue; // skip languages we can't name
    const list = byLang.get(d.languageId) || [];
    list.push(d);
    byLang.set(d.languageId, list);
  }
  const out: StorefrontLanguage[] = [];
  for (const [languageId, list] of byLang) {
    const chosen =
      (preferProtocol && list.find((d) => d.url.startsWith(`${preferProtocol}:`))) || list[0];
    out.push({
      languageId,
      name: meta[languageId].name,
      localeCode: meta[languageId].localeCode,
      url: chosen.url,
      currencyId: chosen.currencyId,
    });
  }
  // Stable order by locale code so the switcher list doesn't shuffle.
  out.sort((a, b) => a.localeCode.localeCompare(b.localeCode));
  return out;
}

/**
 * Locale-aware variant of findDomainForOrigin. Path-prefixed domains
 * (http://localhost/en-GB, http://localhost/de-DE) share a host, so matching on
 * origin alone can't tell them apart — it returns whichever comes first, which
 * makes a /de-DE page load the en-GB snippet set. Given the request's locale we
 * disambiguate by the domain's language, then fall back to origin matching.
 */
export async function findDomainForLocale(
  origin: string,
  locale?: string
): Promise<SalesChannelDomain | null> {
  if (locale) {
    const [domains, meta] = await Promise.all([getSalesChannelDomains(), getLanguageMeta()]);
    let host: string | null = null;
    try {
      host = new URL(normaliseUrl(origin)).host;
    } catch {
      host = null;
    }
    const sameHost = host
      ? domains.filter((d) => {
          try {
            return new URL(d.url).host === host;
          } catch {
            return false;
          }
        })
      : [];
    const pool = sameHost.length ? sameHost : domains;
    const match = pool.find((d) => meta[d.languageId]?.localeCode === locale);
    if (match) return match;
  }
  return findDomainForOrigin(origin);
}

// ─── Merged storefront snippets, cached per snippetSetId ────────────────────
interface SnippetCacheEntry {
  snippets: Record<string, string>;
  fetchedAt: number;
}
const snippetCache: Record<string, SnippetCacheEntry> = {};
const SNIPPET_TTL_MS = 10 * 60 * 1000;

export async function getSnippetsForSnippetSetId(
  setId: string
): Promise<Record<string, string>> {
  const cached = snippetCache[setId];
  if (cached && Date.now() - cached.fetchedAt < SNIPPET_TTL_MS) {
    return cached.snippets;
  }
  try {
    const token = await getAdminToken();
    const snippets: Record<string, string> = {};
    const PAGE_SIZE = 500;
    // Paginate /api/search/snippet — file-based defaults that haven't been
    // customised don't live in the DB, so only edited snippets come back.
    // That matches our "fallback wins when missing" semantics: the t(key, fb)
    // call uses the snippet only when the merchant has authored it in admin.
    for (let page = 1; page < 50; page++) {
      const res = await fetch(`${SHOPWARE_URL}/api/search/snippet`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          filter: [{ type: 'equals', field: 'setId', value: setId }],
          limit: PAGE_SIZE,
          page,
          'total-count-mode': 1,
        }),
        cache: 'no-store',
      });
      if (!res.ok) break;
      const data: {
        data?: Array<{ attributes?: { translationKey?: string; value?: string } }>;
      } = await res.json();
      const entries = data.data || [];
      for (const e of entries) {
        const k = e.attributes?.translationKey;
        const v = e.attributes?.value;
        if (k && v != null) snippets[k] = v;
      }
      if (entries.length < PAGE_SIZE) break;
    }
    snippetCache[setId] = { snippets, fetchedAt: Date.now() };
    return snippets;
  } catch {
    return cached?.snippets ?? {};
  }
}

/**
 * Fallback locale-based lookup: pick whichever snippet set has matching iso.
 * Prefer domain-based lookup whenever you can supply the request origin.
 */
export async function getSnippetsForLocale(
  iso: string
): Promise<Record<string, string>> {
  const setId = await getSnippetSetIdByIso(iso);
  if (!setId) return {};
  return getSnippetsForSnippetSetId(setId);
}

// ─── Shop pages (GTC / revocation / privacy / imprint) ──────────────────────
// These are CMS pages referenced from Settings → Basic information → Shop pages
// and stored as system_config values. The Store API has no system-config
// endpoint, so the page IDs are read via the Admin API; the CMS page itself is
// then loaded over the public Store API (/cms/{id}).

export type ShopPageKey = 'tos' | 'revocation' | 'privacy' | 'imprint' | 'shippingPayment';

const SHOP_PAGE_CONFIG_KEYS: Record<ShopPageKey, string> = {
  tos: 'core.basicInformation.tosPage',
  revocation: 'core.basicInformation.revocationPage',
  privacy: 'core.basicInformation.privacyPage',
  imprint: 'core.basicInformation.imprintPage',
  shippingPayment: 'core.basicInformation.shippingPaymentInfoPage',
};

let shopPageIdCache: Record<string, string | null> | null = null;
let shopPageIdCacheAt = 0;
const SHOP_PAGE_TTL_MS = 10 * 60 * 1000;

function readConfigValue(raw: any): string | null {
  if (raw == null) return null;
  if (typeof raw === 'string') return raw || null;
  // system_config stores values JSON-wrapped as { _value: ... } in some shapes
  if (typeof raw === 'object' && typeof raw._value === 'string') return raw._value || null;
  return null;
}

async function loadShopPageIds(): Promise<Record<string, string | null>> {
  const map: Record<string, string | null> = {};
  if (!isAdminApiConfigured()) return map;
  try {
    const token = await getAdminToken();
    const res = await fetch(`${SHOPWARE_URL}/api/search/system-config`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({
        filter: [
          {
            type: 'equalsAny',
            field: 'configurationKey',
            value: Object.values(SHOP_PAGE_CONFIG_KEYS),
          },
        ],
        limit: 100,
      }),
      cache: 'no-store',
    });
    if (!res.ok) return map;
    const data: { data?: Array<any> } = await res.json();
    const rows = data.data || [];

    // Reverse-map configurationKey → our short key
    const keyByConfig = Object.fromEntries(
      Object.entries(SHOP_PAGE_CONFIG_KEYS).map(([k, v]) => [v, k])
    ) as Record<string, ShopPageKey>;

    for (const [, configKey] of Object.entries(SHOP_PAGE_CONFIG_KEYS)) {
      // Prefer a global (salesChannelId = null) value, else the first match.
      const matches = rows.filter(
        (r) => (r.attributes?.configurationKey ?? r.configurationKey) === configKey
      );
      const global = matches.find(
        (r) => (r.attributes?.salesChannelId ?? r.salesChannelId) == null
      );
      const chosen = global ?? matches[0];
      const value = readConfigValue(
        chosen?.attributes?.configurationValue ?? chosen?.configurationValue
      );
      map[keyByConfig[configKey]] = value;
    }
    return map;
  } catch {
    return map;
  }
}

/** Resolve the CMS page ID configured for a shop page (GTC, revocation, …). */
export async function getShopPageCmsId(key: ShopPageKey): Promise<string | null> {
  const now = Date.now();
  if (!shopPageIdCache || now - shopPageIdCacheAt > SHOP_PAGE_TTL_MS) {
    shopPageIdCache = await loadShopPageIds();
    shopPageIdCacheAt = now;
  }
  return shopPageIdCache?.[key] ?? null;
}

// ─── Login & registration settings ──────────────────────────────────────────

export interface RegistrationSettings {
  /** Show the private/commercial account-type selector on the register form. */
  showAccountTypeSelection: boolean;
}

let regSettingsCache: { value: RegistrationSettings; at: number } | null = null;
const REG_SETTINGS_TTL_MS = 10 * 60 * 1000;

function readBoolConfig(raw: any): boolean | null {
  if (typeof raw === 'boolean') return raw;
  if (raw && typeof raw === 'object' && typeof raw._value === 'boolean') return raw._value;
  return null;
}

/** Unwrap a system_config value that may hold a JSON object (or {_value:…}). */
function readObjectConfig(raw: any): any {
  if (raw == null) return null;
  if (typeof raw === 'string') {
    try {
      return JSON.parse(raw);
    } catch {
      return null;
    }
  }
  if (typeof raw === 'object') {
    if ('_value' in raw) return (raw as any)._value;
    return raw;
  }
  return null;
}

export async function getRegistrationSettings(): Promise<RegistrationSettings> {
  const now = Date.now();
  if (regSettingsCache && now - regSettingsCache.at < REG_SETTINGS_TTL_MS) {
    return regSettingsCache.value;
  }
  // Default to showing the selector so B2B registration stays available even if
  // the Admin API isn't reachable.
  let value: RegistrationSettings = { showAccountTypeSelection: true };

  if (isAdminApiConfigured()) {
    try {
      const token = await getAdminToken();
      const res = await fetch(`${SHOPWARE_URL}/api/search/system-config`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          filter: [
            {
              type: 'equals',
              field: 'configurationKey',
              value: 'core.loginRegistration.showAccountTypeSelection',
            },
          ],
          limit: 10,
        }),
        cache: 'no-store',
      });
      if (res.ok) {
        const data: { data?: Array<any> } = await res.json();
        const rows = data.data || [];
        const global = rows.find(
          (r) => (r.attributes?.salesChannelId ?? r.salesChannelId) == null
        );
        const chosen = global ?? rows[0];
        const bool = readBoolConfig(
          chosen?.attributes?.configurationValue ?? chosen?.configurationValue
        );
        if (bool !== null) value = { showAccountTypeSelection: bool };
      }
    } catch {
      // keep default
    }
  }

  regSettingsCache = { value, at: now };
  return value;
}

// ─── Storefront security & contact-form settings ────────────────────────────
// Settings → Basic information → "Security and Privacy" card. These drive the
// contact form's required fields and the cookie-consent banner. Stored as
// system_config; read via the Admin API (the Store API has no equivalent).

export interface StorefrontSecuritySettings {
  /** Contact form: first name is a required field. */
  firstNameFieldRequired: boolean;
  /** Contact form: last name is a required field. */
  lastNameFieldRequired: boolean;
  /** Contact form: phone number is a required field. */
  phoneNumberFieldRequired: boolean;
  /** Render the default cookie-consent banner. */
  useDefaultCookieConsent: boolean;
  /** Show the "Accept all" button in the cookie banner. */
  acceptAllCookies: boolean;
}

const SECURITY_CONFIG_KEYS: Record<keyof StorefrontSecuritySettings, string> = {
  firstNameFieldRequired: 'core.basicInformation.firstNameFieldRequired',
  lastNameFieldRequired: 'core.basicInformation.lastNameFieldRequired',
  phoneNumberFieldRequired: 'core.basicInformation.phoneNumberFieldRequired',
  useDefaultCookieConsent: 'core.basicInformation.useDefaultCookieConsent',
  acceptAllCookies: 'core.basicInformation.acceptAllCookies',
};

// Defaults mirror Shopware's config.xml defaults, used when the Admin API is
// unreachable or a key has never been overridden in the admin.
const SECURITY_DEFAULTS: StorefrontSecuritySettings = {
  firstNameFieldRequired: false,
  lastNameFieldRequired: false,
  phoneNumberFieldRequired: false,
  useDefaultCookieConsent: true,
  acceptAllCookies: false,
};

let securitySettingsCache: { value: StorefrontSecuritySettings; at: number } | null = null;
// Short TTL: admins expect "Security and Privacy" toggles to reflect quickly.
const SECURITY_SETTINGS_TTL_MS = 30 * 1000;

/** Read the "Security and Privacy" settings (contact-form + cookie banner). */
export async function getStorefrontSecuritySettings(): Promise<StorefrontSecuritySettings> {
  const now = Date.now();
  if (securitySettingsCache && now - securitySettingsCache.at < SECURITY_SETTINGS_TTL_MS) {
    return securitySettingsCache.value;
  }
  const value: StorefrontSecuritySettings = { ...SECURITY_DEFAULTS };

  if (isAdminApiConfigured()) {
    try {
      const token = await getAdminToken();
      const res = await fetch(`${SHOPWARE_URL}/api/search/system-config`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          filter: [
            {
              type: 'equalsAny',
              field: 'configurationKey',
              value: Object.values(SECURITY_CONFIG_KEYS),
            },
          ],
          limit: 100,
        }),
        cache: 'no-store',
      });
      if (res.ok) {
        const data: { data?: Array<any> } = await res.json();
        const rows = data.data || [];
        for (const [shortKey, configKey] of Object.entries(SECURITY_CONFIG_KEYS) as [
          keyof StorefrontSecuritySettings,
          string,
        ][]) {
          const matches = rows.filter(
            (r) => (r.attributes?.configurationKey ?? r.configurationKey) === configKey
          );
          // Prefer a global (salesChannelId = null) value, else the first match.
          const global = matches.find(
            (r) => (r.attributes?.salesChannelId ?? r.salesChannelId) == null
          );
          const chosen = global ?? matches[0];
          const bool = readBoolConfig(
            chosen?.attributes?.configurationValue ?? chosen?.configurationValue
          );
          if (bool !== null) value[shortKey] = bool;
        }
      }
    } catch {
      // keep defaults
    }
  }

  securitySettingsCache = { value, at: now };
  return value;
}

// ─── Active CAPTCHAs (Settings → Basic information → CAPTCHA) ─────────────────
// Shopware stores the per-channel CAPTCHA selection in a single JSON system_config
// value (`core.basicInformation.activeCaptchasV2`). Multiple CAPTCHAs can be
// active simultaneously. This server-only reader returns the full config —
// INCLUDING the reCAPTCHA secret keys — so it must never be exposed to the
// browser directly; the /api/captcha/config route strips secrets before sending
// a public projection to the client.

export interface ActiveCaptchasConfig {
  honeypot: boolean;
  basicCaptcha: boolean;
  googleReCaptchaV2: { siteKey: string; secretKey: string; invisible: boolean } | null;
  googleReCaptchaV3: { siteKey: string; secretKey: string; thresholdScore: number } | null;
}

const ACTIVE_CAPTCHAS_CONFIG_KEY = 'core.basicInformation.activeCaptchasV2';

const ACTIVE_CAPTCHAS_DEFAULTS: ActiveCaptchasConfig = {
  honeypot: false,
  basicCaptcha: false,
  googleReCaptchaV2: null,
  googleReCaptchaV3: null,
};

let activeCaptchasCache: { value: ActiveCaptchasConfig; at: number } | null = null;
// Short TTL so toggling CAPTCHAs in admin reflects on the storefront quickly.
const ACTIVE_CAPTCHAS_TTL_MS = 60 * 1000;

export async function getActiveCaptchas(): Promise<ActiveCaptchasConfig> {
  const now = Date.now();
  if (activeCaptchasCache && now - activeCaptchasCache.at < ACTIVE_CAPTCHAS_TTL_MS) {
    return activeCaptchasCache.value;
  }
  const value: ActiveCaptchasConfig = { ...ACTIVE_CAPTCHAS_DEFAULTS };

  if (isAdminApiConfigured()) {
    try {
      const token = await getAdminToken();
      const res = await fetch(`${SHOPWARE_URL}/api/search/system-config`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          filter: [
            { type: 'equals', field: 'configurationKey', value: ACTIVE_CAPTCHAS_CONFIG_KEY },
          ],
          limit: 25,
        }),
        cache: 'no-store',
      });
      if (res.ok) {
        const data: { data?: Array<any> } = await res.json();
        const rows = data.data || [];
        // Prefer a global (salesChannelId = null) value, else the first match.
        const global = rows.find(
          (r) => (r.attributes?.salesChannelId ?? r.salesChannelId) == null
        );
        const chosen = global ?? rows[0];
        const cfg = readObjectConfig(
          chosen?.attributes?.configurationValue ?? chosen?.configurationValue
        );
        if (cfg && typeof cfg === 'object') {
          value.honeypot = Boolean(cfg.honeypot?.isActive);
          value.basicCaptcha = Boolean(cfg.basicCaptcha?.isActive);
          if (cfg.googleReCaptchaV2?.isActive) {
            value.googleReCaptchaV2 = {
              siteKey: String(cfg.googleReCaptchaV2.config?.siteKey ?? ''),
              secretKey: String(cfg.googleReCaptchaV2.config?.secretKey ?? ''),
              invisible: Boolean(cfg.googleReCaptchaV2.config?.invisible),
            };
          }
          if (cfg.googleReCaptchaV3?.isActive) {
            value.googleReCaptchaV3 = {
              siteKey: String(cfg.googleReCaptchaV3.config?.siteKey ?? ''),
              secretKey: String(cfg.googleReCaptchaV3.config?.secretKey ?? ''),
              thresholdScore: Number(cfg.googleReCaptchaV3.config?.thresholdScore ?? 0.5),
            };
          }
        }
      }
    } catch {
      // keep defaults
    }
  }

  activeCaptchasCache = { value, at: now };
  return value;
}
