/**
 * Sitemap regeneration + search-engine ping orchestration.
 *
 * Triggered as a best-effort side effect after public content is published,
 * updated, or removed (see `actions.ts` → `sitemap-notify` pending effect).
 * The CMS serves `/sitemap.xml` dynamically, so "regeneration" here means:
 *   1. bump `sitemapLastGeneratedAt` so the Settings/Technical UI reflects the
 *      change, and
 *   2. invalidate the sitemap cache tag so the next request rebuilds it.
 *
 * Pinging is **production-gated** and **SSRF-aware**:
 *   - Pings only fire when the resolved environment is `production` (or the
 *     caller explicitly opts in via `allowNonProduction`), so preview/local
 *     deploys never notify Google/Bing about throwaway URLs.
 *   - The site URL is validated as a public http(s) URL before it's embedded
 *     in a ping request, so a misconfigured `siteUrl` can't point search
 *     engines (or our own fetch) at an internal address.
 *
 * Note on endpoints: Google retired its standalone sitemap-ping endpoint in
 * 2023 (it now returns 404) and recommends discovery via robots.txt / Search
 * Console instead. We keep the call for completeness/back-compat but treat any
 * non-2xx response as a soft failure — Bing's endpoint still honors pings.
 */
import { resolveSeoEnvironment } from './robots.js';
import { validateWebhookUrl } from '../security/webhook.js';
/** Build the canonical `/sitemap.xml` URL from a configured site URL. */
export function buildSitemapUrl(siteUrl) {
    if (!siteUrl)
        return null;
    try {
        const u = new URL(siteUrl);
        if (u.protocol !== 'https:' && u.protocol !== 'http:')
            return null;
        return `${u.origin}/sitemap.xml`;
    }
    catch {
        return null;
    }
}
/** Search-engine ping targets for a given (already validated) sitemap URL. */
export function buildSearchEnginePings(sitemapUrl) {
    const enc = encodeURIComponent(sitemapUrl);
    return [
        { name: 'google', url: `https://www.google.com/ping?sitemap=${enc}` },
        { name: 'bing', url: `https://www.bing.com/ping?sitemap=${enc}` },
    ];
}
/**
 * Default debounce window for the expensive half of the cycle (timestamp write
 * + search-engine ping). Bursts of publishes (bulk imports, "publish all")
 * collapse to one regeneration per window so we don't hammer the single hot
 * `__seo_config__` row or spam search engines. Cache invalidation is NOT
 * debounced — it's cheap and must happen on every change for correctness.
 */
export const DEFAULT_SITEMAP_DEBOUNCE_MS = 60_000;
/**
 * Decide what to do after a public-content change and perform the pings.
 * Pure-ish: all I/O goes through `fetchImpl` and the result tells the caller
 * whether to persist a regeneration timestamp. Never throws — sitemap upkeep
 * must not fail a content write that already committed.
 */
export async function notifySitemapOnPublish(opts) {
    const { settings } = opts;
    const result = {
        invalidateCache: false,
        regenerated: false,
        sitemapUrl: null,
        pinged: [],
        skipped: [],
    };
    // Sitemap disabled entirely → nothing to do (no cache drop, no write).
    if (!settings.sitemapEnabled) {
        result.skipped.push({ target: 'sitemap', reason: 'sitemap-disabled' });
        return result;
    }
    // Auto-regenerate off → the admin opted out of publish-time refresh entirely.
    if (!settings.autoRegenerateSitemapOnPublish) {
        result.skipped.push({ target: 'sitemap', reason: 'auto-regenerate-disabled' });
        return result;
    }
    // The sitemap content changed and auto-regenerate is on → the cache must be
    // dropped on EVERY change so the next request rebuilds. This is the cheap,
    // un-debounced half of the cycle.
    result.invalidateCache = true;
    // Debounce the expensive half (DB timestamp write + outbound ping): if we
    // regenerated within the window, collapse this publish into the prior one.
    const debounceMs = opts.debounceMs ?? DEFAULT_SITEMAP_DEBOUNCE_MS;
    const lastMs = opts.lastGeneratedAt ? Date.parse(opts.lastGeneratedAt) : NaN;
    if (!Number.isNaN(lastMs) && Date.now() - lastMs < debounceMs) {
        result.skipped.push({ target: 'sitemap', reason: 'debounced' });
        return result;
    }
    result.regenerated = true;
    if (!settings.pingSearchEnginesOnPublish) {
        result.skipped.push({ target: 'ping', reason: 'ping-disabled' });
        return result;
    }
    const environment = resolveSeoEnvironment(opts.environment);
    const isProduction = environment === 'production';
    if (!isProduction && !opts.allowNonProduction) {
        result.skipped.push({ target: 'ping', reason: `non-production:${environment ?? 'unknown'}` });
        return result;
    }
    const sitemapUrl = buildSitemapUrl(opts.siteUrl);
    if (!sitemapUrl) {
        result.skipped.push({ target: 'ping', reason: 'missing-or-invalid-site-url' });
        return result;
    }
    result.sitemapUrl = sitemapUrl;
    // In production the site URL must be a public https URL — reject internal /
    // http / IP-literal targets so a misconfigured siteUrl can't be weaponized.
    const urlCheck = validateWebhookUrl(sitemapUrl);
    if (!urlCheck.valid) {
        result.skipped.push({
            target: 'ping',
            reason: `unsafe-site-url:${urlCheck.error ?? 'invalid'}`,
        });
        return result;
    }
    if (isProduction && !sitemapUrl.startsWith('https://')) {
        result.skipped.push({ target: 'ping', reason: 'non-https-site-url' });
        return result;
    }
    const doFetch = opts.fetchImpl ?? globalThis.fetch;
    if (typeof doFetch !== 'function') {
        result.skipped.push({ target: 'ping', reason: 'fetch-unavailable' });
        return result;
    }
    const timeoutMs = opts.timeoutMs ?? 5000;
    const targets = buildSearchEnginePings(sitemapUrl);
    await Promise.all(targets.map(async (target) => {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const res = await doFetch(target.url, {
                method: 'GET',
                redirect: 'error',
                signal: controller.signal,
            });
            if (res.ok) {
                result.pinged.push(target.name);
            }
            else {
                result.skipped.push({ target: target.name, reason: `http-${res.status}` });
            }
        }
        catch (err) {
            result.skipped.push({
                target: target.name,
                reason: err instanceof Error ? `fetch-error:${err.name}` : 'fetch-error',
            });
        }
        finally {
            clearTimeout(timer);
        }
    }));
    return result;
}
//# sourceMappingURL=sitemap-notify.js.map