import { logEvent } from '../../security/audit.js';
import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
import { getActuateConfig } from '../../config/runtime.js';
import { guardedDb } from '../guarded-db.js';
import { computeLightSeoScore } from './dashboard.js';
import { json, errorResponse, internalError, escapeXml, SITEMAP_PAGE_SIZE, renderOgSvg, safeFindMany, requireAuth, WRITE_ROLES, ADMIN_ROLES, requireRole, enforceDocumentReadAccess, } from '../route-helpers.js';
/**
 * Load the runtime config with DB-stored SEO overrides applied. Used by every
 * public SEO surface (sitemap, robots, og.png) and by /resolve so admin edits
 * in the Settings → SEO panel take effect without a redeploy.
 *
 * Falls back to the static config when no override row exists yet — first
 * boot of a freshly-installed CMS keeps working.
 */
export async function loadEffectiveConfig() {
    const base = getActuateConfig();
    if (!base)
        return null;
    try {
        const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
        const overrides = await getSeoOverrides(guardedDb());
        return applySeoOverrides(base, overrides);
    }
    catch {
        return base;
    }
}
export function siteUrlFromRequest(request, cfg) {
    const seo = (cfg ?? getActuateConfig())?.seo;
    if (seo?.siteUrl)
        return seo.siteUrl.replace(/\/+$/, '');
    // Fall back to the request origin so the routes work on preview deploys
    // before the integrator has configured siteUrl.
    try {
        const u = new URL(request.url);
        return `${u.protocol}//${u.host}`;
    }
    catch {
        return '';
    }
}
export function registerSeoPublicRoutes(router) {
    const db = guardedDb;
    // ---------------------------------------------------------------------------
    // Public SEO surfaces: sitemap.xml, per-collection sitemaps, robots.txt,
    // and the dynamic /og.png OG-image endpoint. These are unauthenticated by
    // design — search engines and social crawlers will fetch them.
    // ---------------------------------------------------------------------------
    function sitemapEligibleCollections(cfg) {
        const resolved = cfg ?? getActuateConfig();
        if (!resolved)
            return [];
        const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
        const out = [];
        for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
            // Only publicly-rendered content types belong in the sitemap. Structural
            // / utility collections (navigation menus, footers, tags, templates) have
            // no `type` and must never produce indexable sitemap URLs.
            if (col.type !== 'page' && col.type !== 'post')
                continue;
            if (col.admin?.hidden)
                continue;
            if (excluded.has(slug))
                continue;
            if (col.seo?.excludeFromSitemap)
                continue;
            out.push({ slug, urlPrefix: col.urlPrefix, type: col.type, seo: col.seo });
        }
        return out;
    }
    router.get('/sitemap.xml', async (request) => {
        try {
            const cfg = await loadEffectiveConfig();
            if (cfg?.seo?.sitemap?.disabled)
                return errorResponse('Sitemap disabled', 404);
            const base = siteUrlFromRequest(request, cfg);
            const cols = sitemapEligibleCollections(cfg);
            // Sitemap index points at /sitemaps/:slug.xml for each collection. A
            // collection with more than SITEMAP_PAGE_SIZE published docs is split
            // across `?page=N` files so we never silently truncate (the old code
            // capped at 5,000 and dropped the rest).
            const now = new Date().toISOString();
            const perCollection = await Promise.all(cols.map(async (c) => {
                let count = 0;
                try {
                    count = await db().document.count({
                        where: { collection: c.slug, deletedAt: null, status: 'PUBLISHED' },
                    });
                }
                catch {
                    count = 0;
                }
                const pages = Math.max(1, Math.ceil(count / SITEMAP_PAGE_SIZE));
                const entries = [];
                for (let page = 0; page < pages; page++) {
                    const loc = page === 0
                        ? `${base}/api/cms/sitemaps/${c.slug}.xml`
                        : `${base}/api/cms/sitemaps/${c.slug}.xml?page=${page}`;
                    entries.push([
                        '  <sitemap>',
                        `    <loc>${escapeXml(loc)}</loc>`,
                        `    <lastmod>${now}</lastmod>`,
                        '  </sitemap>',
                    ].join('\n'));
                }
                return entries.join('\n');
            }));
            const sitemaps = perCollection.filter(Boolean).join('\n');
            const xml = [
                '<?xml version="1.0" encoding="UTF-8"?>',
                '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
                sitemaps,
                '</sitemapindex>',
            ].join('\n');
            return new Response(xml, {
                status: 200,
                headers: {
                    'Content-Type': 'application/xml; charset=utf-8',
                    'Cache-Control': 'public, max-age=300, s-maxage=600',
                },
            });
        }
        catch (err) {
            return internalError(err, 'sitemap.xml');
        }
    });
    // The router's path-param syntax (`:name`) greedily matches everything up
    // to the next `/`, so registering as `/sitemaps/:slug.xml` would capture
    // `slug.xml` into a single param. We register `/sitemaps/:slug` instead and
    // require the `.xml` suffix in the handler.
    router.get('/sitemaps/:slug', async (request, params) => {
        try {
            const cfg = await loadEffectiveConfig();
            if (cfg?.seo?.sitemap?.disabled)
                return errorResponse('Sitemap disabled', 404);
            const slugParam = params.slug ?? '';
            if (!/\.xml$/i.test(slugParam))
                return errorResponse('Sitemap not found', 404);
            const rawSlug = slugParam.replace(/\.xml$/i, '');
            const collection = cfg?.collections?.[rawSlug];
            if (!collection)
                return errorResponse('Collection not found', 404);
            // Only publicly-rendered page/post collections are sitemap-eligible —
            // structural/utility collections (menus, tags, templates) are not.
            if (collection.type !== 'page' && collection.type !== 'post')
                return errorResponse('Collection excluded from sitemap', 404);
            if (collection.admin?.hidden)
                return errorResponse('Collection excluded from sitemap', 404);
            if (collection.seo?.excludeFromSitemap)
                return errorResponse('Collection excluded from sitemap', 404);
            const base = siteUrlFromRequest(request, cfg);
            const pageParam = Number(new URL(request.url).searchParams.get('page') ?? '0');
            const page = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 0;
            const docs = await db().document.findMany({
                where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
                select: { slug: true, updatedAt: true, data: true },
                orderBy: { updatedAt: 'desc' },
                skip: page * SITEMAP_PAGE_SIZE,
                take: SITEMAP_PAGE_SIZE,
            });
            const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
            const defaultPriority = collection.seo?.sitemapPriority ??
                cfg?.seo?.sitemap?.defaultPriority ??
                (collection.type === 'page' ? 0.8 : 0.6);
            const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
            const urls = [];
            // Archive page first (e.g. /blog) for post-type collections — only on
            // the first page so it isn't duplicated across paginated sitemap files.
            if (page === 0 && collection.seo?.archivePath && collection.type === 'post') {
                const archive = collection.seo.archivePath.startsWith('http')
                    ? collection.seo.archivePath
                    : `${base}${collection.seo.archivePath}`;
                urls.push([
                    '  <url>',
                    `    <loc>${escapeXml(archive)}</loc>`,
                    `    <lastmod>${new Date().toISOString()}</lastmod>`,
                    `    <changefreq>${changefreq}</changefreq>`,
                    '    <priority>0.6</priority>',
                    '  </url>',
                ].join('\n'));
            }
            for (const d of docs) {
                const data = d.data || {};
                const slug = d.slug ?? data.slug;
                if (!slug)
                    continue;
                const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
                urls.push([
                    '  <url>',
                    `    <loc>${escapeXml(loc)}</loc>`,
                    `    <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
                    `    <changefreq>${changefreq}</changefreq>`,
                    `    <priority>${defaultPriority.toFixed(1)}</priority>`,
                    '  </url>',
                ].join('\n'));
            }
            const xml = [
                '<?xml version="1.0" encoding="UTF-8"?>',
                '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
                urls.join('\n'),
                '</urlset>',
            ].join('\n');
            return new Response(xml, {
                status: 200,
                headers: {
                    'Content-Type': 'application/xml; charset=utf-8',
                    'Cache-Control': 'public, max-age=300, s-maxage=600',
                },
            });
        }
        catch (err) {
            return internalError(err, 'sitemaps/:slug.xml');
        }
    });
    router.get('/robots.txt', async (request) => {
        try {
            const cfg = await loadEffectiveConfig();
            const seo = cfg?.seo;
            if (seo?.robots?.disabled)
                return errorResponse('robots.txt disabled', 404);
            // Admin-edited custom robots.txt body (Technical tab) wins when set —
            // the editor is the single source of truth for operators who want full
            // control over directives.
            try {
                const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
                const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
                if (settings.robotsEnabled === false)
                    return errorResponse('robots.txt disabled', 404);
                const custom = settings.robotsTxt?.trim();
                if (custom) {
                    return new Response(settings.robotsTxt, {
                        status: 200,
                        headers: {
                            'Content-Type': 'text/plain; charset=utf-8',
                            'Cache-Control': 'public, max-age=3600, s-maxage=3600',
                        },
                    });
                }
            }
            catch {
                // Fall through to generated defaults on any override-read failure.
            }
            const base = siteUrlFromRequest(request, cfg);
            const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
            const lines = [
                'User-agent: *',
                'Allow: /',
                'Disallow: /admin',
                'Disallow: /api/',
                '',
            ];
            for (const rule of seo?.robots?.additionalRules ?? []) {
                lines.push(`User-agent: ${rule.userAgent}`);
                for (const allow of rule.allow ?? [])
                    lines.push(`Allow: ${allow}`);
                for (const dis of rule.disallow ?? [])
                    lines.push(`Disallow: ${dis}`);
                lines.push('');
            }
            if (seo?.robots?.blockAIBots) {
                const bots = [
                    'GPTBot',
                    'ChatGPT-User',
                    'ClaudeBot',
                    'Claude-Web',
                    'anthropic-ai',
                    'Bytespider',
                    'CCBot',
                    'Google-Extended',
                ];
                for (const bot of bots) {
                    lines.push(`User-agent: ${bot}`);
                    lines.push('Disallow: /');
                    lines.push('');
                }
            }
            if (sitemapUrl)
                lines.push(`Sitemap: ${sitemapUrl}`, '');
            return new Response(lines.join('\n'), {
                status: 200,
                headers: {
                    'Content-Type': 'text/plain; charset=utf-8',
                    'Cache-Control': 'public, max-age=3600, s-maxage=3600',
                },
            });
        }
        catch (err) {
            return internalError(err, 'robots.txt');
        }
    });
    router.get('/og.png', async (request) => {
        try {
            const cfg = await loadEffectiveConfig();
            if (cfg?.seo?.ogImage?.disabled)
                return errorResponse('og.png disabled', 404);
            const url = new URL(request.url);
            const title = url.searchParams.get('title') ?? cfg?.seo?.siteName ?? 'Untitled';
            const description = url.searchParams.get('description') ?? undefined;
            const siteName = url.searchParams.get('siteName') ?? cfg?.seo?.siteName;
            const theme = url.searchParams.get('theme') ?? cfg?.seo?.ogImage?.theme ?? 'light';
            const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
            const fg = theme === 'dark' ? '#fafafa' : '#0a0a0a';
            const muted = theme === 'dark' ? '#a1a1aa' : '#71717a';
            // SVG OG image. Crawlers (Facebook, Twitter/X, LinkedIn, Slack, Discord)
            // all accept image/svg+xml when served with the right Content-Type. We
            // chose SVG over PNG to avoid forcing a Satori/resvg dependency on
            // every integrator; sites that need PNG can override with `og:image`
            // pointing at their own /og endpoint built with @vercel/og.
            const svg = renderOgSvg({ title, description, siteName, theme, bg, fg, muted });
            return new Response(svg, {
                status: 200,
                headers: {
                    'Content-Type': 'image/svg+xml; charset=utf-8',
                    'Cache-Control': 'public, max-age=86400, s-maxage=86400',
                },
            });
        }
        catch (err) {
            return internalError(err, 'og.png');
        }
    });
    router.get('/seo/schema/:documentId', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const doc = await db().document.findFirst({
                where: { id: params.documentId, deletedAt: null },
            });
            if (!doc)
                return errorResponse('Not found', 404);
            const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
            if (accessErr)
                return accessErr;
            const { buildSchemaGraph } = await import('../../content/structured-data.js');
            const data = doc.data || {};
            const graph = buildSchemaGraph({
                siteName: 'Actuate CMS',
                siteUrl: '',
            }, {
                url: `/${doc.collection}/${doc.slug || ''}`,
                title: doc.title || data.title || '',
                description: data.metaDescription || data.seoDescription || '',
                datePublished: doc.createdAt?.toISOString(),
                dateModified: doc.updatedAt?.toISOString(),
                type: doc.collection === 'posts' ? 'post' : 'page',
                keywords: data.tags || data.keywords,
                featuredImage: data.featuredImage ? { url: data.featuredImage } : undefined,
            });
            return new Response(JSON.stringify(graph), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }
        catch (err) {
            return internalError(err, 'seo/schema');
        }
    });
    router.get('/seo/meta/:documentId', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const doc = await db().document.findFirst({
                where: { id: params.documentId, deletedAt: null },
            });
            if (!doc)
                return errorResponse('Not found', 404);
            const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
            if (accessErr)
                return accessErr;
            const { generateMetaTags } = await import('../../seo/meta-tags.js');
            const { resolveRobotsDirectives } = await import('../../seo/robots.js');
            const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
            const data = doc.data || {};
            // Read robots defaults from the SAME effective SEO config the public
            // renderer (`composePageMeta`) and General Settings use — never the
            // legacy `settings` global — so this preview can't drift from reality.
            const effectiveSeo = applySeoOverrides(getActuateConfig(), await getSeoOverrides(db()))?.seo;
            const robots = resolveRobotsDirectives({
                pageData: data,
                settings: {
                    defaultNoIndex: effectiveSeo?.robots?.defaultNoIndex,
                    defaultNoFollow: effectiveSeo?.robots?.defaultNoFollow,
                    noIndexNonProduction: effectiveSeo?.robots?.noIndexNonProduction,
                },
                environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
            });
            const tags = generateMetaTags({
                title: data.metaTitle || doc.title || data.title || '',
                description: data.metaDescription || data.seoDescription || '',
                url: `/${doc.collection}/${doc.slug || ''}`,
                siteName: 'Actuate CMS',
                type: doc.collection === 'posts' ? 'article' : 'website',
                ogTitle: data.ogTitle,
                ogDescription: data.ogDescription,
                ogImage: data.ogImage ? { url: data.ogImage } : undefined,
                twitterTitle: data.twitterTitle,
                twitterDescription: data.twitterDescription,
                twitterImage: data.twitterImage,
                publishedTime: doc.createdAt?.toISOString(),
                modifiedTime: doc.updatedAt?.toISOString(),
                noIndex: robots.noIndex,
                noFollow: robots.noFollow,
                canonical: data.canonical,
            });
            return new Response(JSON.stringify(tags), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }
        catch (err) {
            return internalError(err, 'seo/meta');
        }
    });
    // ---------------------------------------------------------------------------
    // SEO summary (aggregated dashboard data)
    // ---------------------------------------------------------------------------
    router.get('/seo/summary', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            // EDITOR+ only — this endpoint issues outbound HEAD requests for link
            // health, mirroring the role gate on /seo/link-health.
            const roleErr = requireRole(auth.session.role, WRITE_ROLES);
            if (roleErr)
                return roleErr;
            const docs = (await safeFindMany(db().document, {
                where: { deletedAt: null, status: 'PUBLISHED' },
                select: { id: true, title: true, collection: true, data: true, plainText: true },
                orderBy: { updatedAt: 'desc' },
                take: 50,
            }));
            let missingMetaDescriptions = 0;
            let missingAltText = 0;
            const topContent = [];
            for (const doc of docs) {
                const data = doc.data ?? {};
                if (!data.metaDescription && !data.seoDescription)
                    missingMetaDescriptions++;
                const content = typeof data.body === 'string'
                    ? data.body
                    : typeof data.content === 'string'
                        ? data.content
                        : '';
                if (content) {
                    const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
                    const noAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
                    if (noAlt > 0)
                        missingAltText += noAlt;
                }
                topContent.push({
                    id: doc.id,
                    title: doc.title ?? 'Untitled',
                    collection: doc.collection,
                    score: computeLightSeoScore(data),
                });
            }
            topContent.sort((a, b) => b.score - a.score);
            let brokenInternalLinks = 0;
            try {
                const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
                // Only probe internal links (same-origin as the configured site). When
                // NEXT_PUBLIC_SITE_URL is unset we can't classify any URL as internal,
                // so we skip all probing rather than issuing HEAD requests to arbitrary,
                // document-controlled hosts — that would be an SSRF oracle.
                if (siteUrl) {
                    const linkDocs = docs.slice(0, 10);
                    const urlRegex = /https?:\/\/[^\s"'<>]+/g;
                    for (const doc of linkDocs) {
                        const content = JSON.stringify(doc.data ?? {});
                        const urls = content.match(urlRegex) ?? [];
                        const seen = new Set();
                        for (const url of urls) {
                            const clean = url.replace(/[",;)}\]]+$/, '');
                            if (seen.has(clean) || !clean.startsWith(siteUrl))
                                continue;
                            seen.add(clean);
                            try {
                                // safeFetch rejects private/loopback IPs and disables redirect
                                // following, so even an internal link can't smuggle the probe
                                // onto the internal network.
                                const resp = await safeFetch(clean, { method: 'HEAD', timeoutMs: 3000 });
                                try {
                                    await resp.body?.cancel();
                                }
                                catch {
                                    /* noop */
                                }
                                if (resp.status >= 400)
                                    brokenInternalLinks++;
                            }
                            catch (err) {
                                if (err instanceof SsrfBlockedError)
                                    continue;
                                brokenInternalLinks++;
                            }
                        }
                    }
                }
            }
            catch {
                /* best effort */
            }
            return json({
                data: {
                    totalPages: docs.length,
                    issuesSummary: { missingMetaDescriptions, brokenInternalLinks, missingAltText },
                    topContent: topContent.slice(0, 10),
                },
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
    // ---------------------------------------------------------------------------
    // SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
    // ---------------------------------------------------------------------------
    /**
     * Returns:
     *   - `static`    → the SEO config from `actuate.config.ts` (read-only, code-level)
     *   - `overrides` → the user-edited DB overrides (or null on first boot)
     *   - `effective` → static + overrides merged (what the runtime actually uses)
     *   - `collections` → list of collection slugs + labels the UI should iterate
     *
     * The UI uses `static` to show grey "default" placeholders, `overrides` to
     * populate the form inputs, and writes back just the changed fields via PUT.
     */
    router.get('/seo/config', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const { getSeoOverrides, applySeoOverrides } = await import('../../seo/config-store.js');
            const staticCfg = getActuateConfig();
            const overrides = await getSeoOverrides(db());
            const effective = applySeoOverrides(staticCfg, overrides);
            const collections = Object.entries(staticCfg?.collections ?? {})
                .map(([slug, col]) => ({
                slug,
                label: col.labels?.plural ?? slug,
                type: col.type ?? 'page',
                urlPrefix: col.urlPrefix,
                staticSeo: col.seo ?? null,
                effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
            }))
                .sort((a, b) => a.label.localeCompare(b.label));
            return json({
                data: {
                    static: { site: staticCfg?.seo ?? null },
                    overrides: overrides ?? null,
                    effective: { site: effective?.seo ?? null },
                    collections,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/config GET');
        }
    });
    router.put('/seo/config', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const body = (await request.json().catch(() => null));
            if (!body || typeof body !== 'object') {
                return errorResponse('Request body must be an object', 400);
            }
            // Shallow shape validation — we don't want to mirror the entire type
            // here (the config-store merger ignores unknown keys anyway), but we do
            // want to reject obviously malformed payloads early so the UI gets a
            // clear 400 instead of a silent no-op.
            const site = body.site && typeof body.site === 'object' ? body.site : undefined;
            const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
            if (site === undefined && collections === undefined) {
                return errorResponse('Body must include at least one of `site` or `collections`', 400);
            }
            // Validate the title template server-side so an unsupported token can't be
            // persisted and then silently break public title rendering.
            if (site && typeof site.titleTemplate === 'string') {
                const { validateTitleTemplate } = await import('../../seo/title-templates.js');
                const result = validateTitleTemplate(site.titleTemplate);
                if (!result.valid) {
                    return errorResponse(result.errors.join(' '), 400);
                }
            }
            const { putSeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
            // Merge with whatever's currently stored so the UI can PATCH a single
            // section without wiping the other — `PUT /seo/config { site: {...} }`
            // shouldn't blow away per-collection settings.
            const current = (await getSeoOverrides(db())) ?? {};
            const next = {
                site: site !== undefined ? site : current.site,
                collections: collections !== undefined
                    ? { ...(current.collections ?? {}), ...collections }
                    : current.collections,
                // Preserve Technical-tab module settings so editing site/collection
                // defaults never silently wipes crawl/robots/sitemap configuration.
                module: current.module,
            };
            const saved = await putSeoOverrides(db(), next, auth.session.userId);
            // Audit so changes to public SEO surface are traceable. We log a
            // field-level diff (previous → new) for the site defaults, but redact
            // verification token VALUES — only record which providers changed, never
            // the token itself, since those land in public <meta> tags and shouldn't
            // be duplicated into the audit trail.
            try {
                const { resolveSeoEnvironment } = await import('../../seo/robots.js');
                const prevSite = (current.site ?? {});
                const nextSite = (site ?? prevSite);
                const changedFields = [];
                for (const key of new Set([...Object.keys(prevSite), ...Object.keys(nextSite)])) {
                    if (key === 'verification')
                        continue;
                    if (JSON.stringify(prevSite[key]) !== JSON.stringify(nextSite[key])) {
                        changedFields.push({
                            field: key,
                            from: prevSite[key] ?? null,
                            to: nextSite[key] ?? null,
                        });
                    }
                }
                const prevVer = (prevSite.verification ?? {});
                const nextVer = (nextSite.verification ?? {});
                const verificationChanged = Object.keys({ ...prevVer, ...nextVer }).filter((k) => JSON.stringify(prevVer[k]) !== JSON.stringify(nextVer[k]));
                await logEvent({
                    event: 'update_seo_config',
                    userId: auth.session.userId,
                    details: {
                        changedFields,
                        verificationChanged,
                        collectionKeys: Object.keys(collections ?? {}),
                        environment: resolveSeoEnvironment(),
                        source: 'settings.seo',
                    },
                });
            }
            catch {
                // Audit failures must never block the write.
            }
            return json({ data: saved });
        }
        catch (err) {
            return internalError(err, 'seo/config PUT');
        }
    });
    // Effective, computed crawl/index status for the General Settings card.
    // Reads the SAME effective SEO config the public renderer uses, so the badge
    // shown to editors never drifts from what search engines actually see.
    router.get('/seo/index-status', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const { getSiteIndexStatus } = await import('../../seo/index-status.js');
            const status = await getSiteIndexStatus(db());
            return json({ data: status });
        }
        catch (err) {
            return internalError(err, 'seo/index-status GET');
        }
    });
    // ---------------------------------------------------------------------------
    // Technical SEO — sitemap status, crawl settings, robots.txt editor.
    // All backed by the `module` block of the SEO overrides document.
    // ---------------------------------------------------------------------------
    /** Persist a partial patch onto the SEO module settings, preserving the rest. */
    async function patchSeoModule(patch, userId) {
        const { getSeoOverrides, putSeoOverrides } = await import('../../seo/config-store.js');
        const current = (await getSeoOverrides(db())) ?? {};
        await putSeoOverrides(db(), {
            site: current.site,
            collections: current.collections,
            module: { ...(current.module ?? {}), ...patch },
        }, userId);
    }
    /** Build the default robots.txt body (mirrors the public /robots.txt route). */
    function buildDefaultRobots(request, cfg) {
        const base = siteUrlFromRequest(request, cfg);
        const sitemapUrl = cfg?.seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
        const lines = ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', ''];
        if (sitemapUrl)
            lines.push(`Sitemap: ${sitemapUrl}`, '');
        return lines.join('\n');
    }
    router.get('/seo/sitemap/status', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
            const cfg = await loadEffectiveConfig();
            const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
            const base = siteUrlFromRequest(request, cfg);
            const cols = sitemapEligibleCollections(cfg);
            let pagesIncluded = 0;
            let pagesExcluded = 0;
            for (const col of cols) {
                const count = await db().document.count({
                    where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
                });
                pagesIncluded += count;
            }
            // Best-effort: count published docs explicitly flagged noindex.
            try {
                pagesExcluded = await db().document.count({
                    where: {
                        deletedAt: null,
                        status: 'PUBLISHED',
                        data: { path: ['noindex'], equals: true },
                    },
                });
            }
            catch {
                pagesExcluded = 0;
            }
            const warnings = [];
            if (!settings.sitemapEnabled)
                warnings.push('Sitemap generation is disabled in crawl settings.');
            if (pagesIncluded === 0)
                warnings.push('No published pages are eligible for the sitemap.');
            return json({
                data: {
                    sitemapUrl: `${base}/api/cms/sitemap.xml`,
                    lastGeneratedAt: settings.sitemapLastGeneratedAt,
                    pagesIncluded,
                    pagesExcluded,
                    autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
                    errors: [],
                    warnings,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/sitemap/status');
        }
    });
    router.post('/seo/sitemap/regenerate', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const now = new Date().toISOString();
            await patchSeoModule({ sitemapLastGeneratedAt: now }, auth.session.userId);
            const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
            const cfg = await loadEffectiveConfig();
            const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
            const base = siteUrlFromRequest(request, cfg);
            const cols = sitemapEligibleCollections(cfg);
            let pagesIncluded = 0;
            for (const col of cols) {
                pagesIncluded += await db().document.count({
                    where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
                });
            }
            return json({
                data: {
                    sitemapUrl: `${base}/api/cms/sitemap.xml`,
                    lastGeneratedAt: now,
                    pagesIncluded,
                    pagesExcluded: 0,
                    autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
                    errors: [],
                    warnings: [],
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/sitemap/regenerate');
        }
    });
    router.get('/seo/crawl-settings', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
            const s = resolveSeoModuleSettings(await getSeoOverrides(db()));
            return json({
                data: {
                    indexEntireSite: s.indexEntireSite,
                    includeImagesInSitemap: s.includeImagesInSitemap,
                    includePostsInSitemap: s.includePostsInSitemap,
                    noindexPaginatedArchives: s.noindexPaginatedArchives,
                    noindexTagPages: s.noindexTagPages,
                    sitemapEnabled: s.sitemapEnabled,
                    autoRegenerateSitemapOnPublish: s.autoRegenerateSitemapOnPublish,
                    pingSearchEnginesOnPublish: s.pingSearchEnginesOnPublish,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/crawl-settings GET');
        }
    });
    router.put('/seo/crawl-settings', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const body = (await request.json().catch(() => null));
            if (!body || typeof body !== 'object') {
                return errorResponse('Request body must be an object', 400);
            }
            const allowed = [
                'indexEntireSite',
                'includeImagesInSitemap',
                'includePostsInSitemap',
                'noindexPaginatedArchives',
                'noindexTagPages',
                'sitemapEnabled',
                'autoRegenerateSitemapOnPublish',
                'pingSearchEnginesOnPublish',
            ];
            const patch = {};
            for (const key of allowed) {
                if (typeof body[key] === 'boolean')
                    patch[key] = body[key];
            }
            if (Object.keys(patch).length === 0) {
                return errorResponse('No valid crawl settings provided', 400);
            }
            // Snapshot current values so the audit trail records prev → new.
            const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
            const { resolveSeoEnvironment } = await import('../../seo/robots.js');
            const before = resolveSeoModuleSettings(await getSeoOverrides(db()));
            await patchSeoModule(patch, auth.session.userId);
            try {
                const changedFields = Object.keys(patch).map((key) => ({
                    field: key,
                    from: before[key] ?? null,
                    to: patch[key],
                }));
                await logEvent({
                    event: 'update_seo_config',
                    userId: auth.session.userId,
                    details: {
                        section: 'crawl-settings',
                        changedFields,
                        environment: resolveSeoEnvironment(),
                        source: 'settings.seo',
                    },
                });
            }
            catch {
                /* audit best-effort */
            }
            return json({ data: { ok: true } });
        }
        catch (err) {
            return internalError(err, 'seo/crawl-settings PUT');
        }
    });
    router.get('/seo/robots', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
            const cfg = await loadEffectiveConfig();
            const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
            const custom = settings.robotsTxt?.trim();
            const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg);
            return json({ data: { body, generated: !custom } });
        }
        catch (err) {
            return internalError(err, 'seo/robots GET');
        }
    });
    router.put('/seo/robots', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            const body = (await request.json().catch(() => null));
            if (!body || typeof body.body !== 'string') {
                return errorResponse('`body` (string) is required', 400);
            }
            if (body.body.length > 50_000) {
                return errorResponse('robots.txt is too large (max 50KB)', 400);
            }
            await patchSeoModule({ robotsTxt: body.body }, auth.session.userId);
            try {
                await logEvent({
                    event: 'update_seo_config',
                    userId: auth.session.userId,
                    details: { section: 'robots.txt' },
                });
            }
            catch {
                /* audit best-effort */
            }
            return json({ data: { ok: true } });
        }
        catch (err) {
            return internalError(err, 'seo/robots PUT');
        }
    });
}
//# sourceMappingURL=seo-public.js.map