import { updateDocumentSeoFields } from '../../actions.js';
import { logEvent } from '../../security/audit.js';
import { processSeoScan } from '../../cron/index.js';
import { isResolvedIp } from '../../security/client-ip.js';
import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
import { getActuateConfig } from '../../config/runtime.js';
import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
import { guardedDb } from '../guarded-db.js';
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
import { detectAiBot } from '../../seo/ai-bots.js';
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
// alphabetically (critical, info, warning) which wrongly ranks `info` above
// `warning`. Unknown severities sort last.
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
function severityRank(severity) {
    return SEVERITY_RANK[String(severity)] ?? 99;
}
/** Sort issues by risk (critical → warning → info), then most-recent first. */
export function sortIssuesBySeverity(issues) {
    return [...issues].sort((a, b) => {
        const rankDiff = severityRank(a.severity) - severityRank(b.severity);
        if (rankDiff !== 0)
            return rankDiff;
        const at = a.lastDetectedAt ? new Date(a.lastDetectedAt).getTime() : 0;
        const bt = b.lastDetectedAt ? new Date(b.lastDetectedAt).getTime() : 0;
        return bt - at;
    });
}
export function registerSeoRoutes(router) {
    const db = guardedDb;
    // ---------------------------------------------------------------------------
    // SEO routes
    // ---------------------------------------------------------------------------
    router.get('/seo/pages', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const pages = await db().document.findMany({
                where: { deletedAt: null, status: 'PUBLISHED' },
                select: {
                    id: true,
                    collection: true,
                    data: true,
                    updatedAt: true,
                    structuredData: true,
                },
                orderBy: { updatedAt: 'desc' },
            });
            return json({ data: pages.map(normalizeSeoPage) });
        }
        catch (err) {
            return internalError(err);
        }
    });
    router.get('/seo/link-health', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            // EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
            const roleErr = requireRole(auth.session.role, WRITE_ROLES);
            if (roleErr)
                return roleErr;
            // Tight rate limit because each call fans out to many outbound requests.
            const ip = clientIp(request);
            const rateKey = isResolvedIp(ip)
                ? `link-health:${ip}`
                : `link-health-user:${auth.session.userId}`;
            if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
                return errorResponse('Too many link-health scans. Please try again later.', 429);
            }
            const MAX_LINKS_PER_PAGE = 50;
            const MAX_TOTAL_LINKS = 500;
            const PER_LINK_TIMEOUT_MS = 4000;
            const CONCURRENCY = 8;
            const docs = await db().document.findMany({
                where: { deletedAt: null, status: 'PUBLISHED' },
                select: { id: true, title: true, data: true, collection: true },
            });
            const urlRegex = /https?:\/\/[^\s"'<>]+/g;
            const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
            const queue = [];
            const seenGlobal = new Set();
            outer: for (const doc of docs) {
                const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
                const content = JSON.stringify(doc.data ?? {});
                const urls = content.match(urlRegex) ?? [];
                const seenInPage = new Set();
                let countInPage = 0;
                for (const url of urls) {
                    const clean = url.replace(/[",;)}\]]+$/, '');
                    if (seenInPage.has(clean))
                        continue;
                    seenInPage.add(clean);
                    if (seenGlobal.has(clean))
                        continue;
                    seenGlobal.add(clean);
                    if (countInPage >= MAX_LINKS_PER_PAGE)
                        break;
                    if (queue.length >= MAX_TOTAL_LINKS)
                        break outer;
                    const isInternal = !!siteUrl && clean.startsWith(siteUrl);
                    queue.push({ docId: doc.id, pageTitle, clean, isInternal });
                    countInPage++;
                }
            }
            const linkResults = [];
            let cursor = 0;
            const worker = async () => {
                while (cursor < queue.length) {
                    const idx = cursor++;
                    const job = queue[idx];
                    let status = 0;
                    try {
                        // safeFetch rejects private/loopback IPs and disables redirect
                        // following so 302->internal can't smuggle the scanner past SSRF.
                        const resp = await safeFetch(job.clean, {
                            method: 'HEAD',
                            timeoutMs: PER_LINK_TIMEOUT_MS,
                        });
                        status = resp.status;
                        // Drain the body so the connection can be reused.
                        try {
                            await resp.body?.cancel();
                        }
                        catch {
                            /* noop */
                        }
                    }
                    catch (err) {
                        status = err instanceof SsrfBlockedError ? -1 : 0;
                    }
                    if (status === -1 || status === 0 || status >= 300) {
                        linkResults.push({
                            id: `${job.docId}-${idx}`,
                            page: job.pageTitle,
                            url: job.clean,
                            status: status === -1 ? 0 : status,
                            type: job.isInternal ? 'internal' : 'external',
                        });
                    }
                }
            };
            await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
            return json({
                data: {
                    truncated: queue.length >= MAX_TOTAL_LINKS,
                    checked: queue.length,
                    issues: linkResults,
                },
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
    router.post('/seo/scan', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, WRITE_ROLES);
            if (roleErr)
                return roleErr;
            // Expensive: loads + regex-scans every published doc. Rate-limit it (same
            // budget as the full audit) so it can't be hammered into an OOM/CPU spike.
            const scanIp = clientIp(request);
            const scanKey = isResolvedIp(scanIp)
                ? `seo-scan:${scanIp}`
                : `seo-scan-user:${auth.session.userId}`;
            if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
                return errorResponse('Too many scans. Please try again later.', 429);
            }
            // Scope the scan to configured front-end content collections so structural
            // (navigation/footer) and benchmark documents are never reported. Shares
            // the exact scan logic with the cron path via processSeoScan.
            const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
            const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
            const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
            return json({ data: result });
        }
        catch (err) {
            return internalError(err);
        }
    });
    // ---------------------------------------------------------------------------
    // SEO Operations Center — audit engine, issues, overview, content records
    // ---------------------------------------------------------------------------
    function seoIssueToApi(i) {
        return {
            id: i.id,
            auditRunId: i.auditRunId ?? null,
            entityType: i.entityType ?? null,
            entityId: i.entityId ?? null,
            entityTitle: i.entityTitle ?? null,
            url: i.url ?? null,
            severity: i.severity,
            category: i.category,
            type: i.type,
            title: i.title,
            description: i.description ?? null,
            impact: i.impact ?? null,
            recommendation: i.recommendation ?? null,
            status: i.status,
            fixable: i.fixable ?? false,
            fixActionType: i.fixActionType ?? null,
            relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
            source: i.source ?? 'audit',
            firstDetectedAt: i.firstDetectedAt ?? null,
            lastDetectedAt: i.lastDetectedAt ?? null,
            resolvedAt: i.resolvedAt ?? null,
        };
    }
    async function buildSeoContentRecords() {
        const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
        const { calculatePageSeoScore } = await import('../../seo/score.js');
        const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
        const entities = await gatherAuditEntities(db(), { collectionTypes });
        return entities.map((e) => {
            const score = calculatePageSeoScore(e);
            return {
                id: `${e.entityType}:${e.entityId}`,
                entityType: e.entityType,
                entityId: e.entityId,
                title: e.title,
                url: e.url,
                canonicalUrl: e.canonicalUrl ?? null,
                metaTitle: e.metaTitle ?? null,
                metaDescription: e.metaDescription ?? null,
                focusKeyword: e.focusKeyword ?? null,
                keyTakeaway: e.keyTakeaway ?? null,
                ogTitle: e.ogTitle ?? null,
                ogDescription: e.ogDescription ?? null,
                ogImageId: null,
                ogImage: e.ogImage ?? null,
                twitterTitle: null,
                twitterDescription: null,
                twitterImageId: null,
                noindex: e.noindex ?? false,
                nofollow: false,
                structuredDataType: e.structuredDataType ?? null,
                structuredData: null,
                // llms.txt curation defaults; the single-record GET overlays the real
                // stored values (the list endpoint doesn't surface these).
                llmsInclude: 'AUTO',
                llmsDescription: null,
                llmsSection: null,
                llmsOptional: false,
                seoScore: score.score,
                seoGrade: score.grade,
                updatedAt: null,
            };
        });
    }
    // Demo/seed search-performance rows live in a hidden collection so they can
    // be inserted by the dev seed script and never ship to npm. In production
    // with a connected Search Console these would come from the GSC API.
    const SEO_PERF_COLLECTION = '__seo_perf__';
    async function loadSearchPerformance(limit = 10) {
        try {
            const docs = await db().document.findMany({
                where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
                select: { data: true },
            });
            const rows = docs
                .map((d) => {
                const data = asRecord(d.data);
                return {
                    url: String(data.url ?? ''),
                    title: String(data.title ?? ''),
                    impressions: Number(data.impressions ?? 0),
                    clicks: Number(data.clicks ?? 0),
                    ctr: Number(data.ctr ?? 0),
                    averagePosition: Number(data.averagePosition ?? 0),
                };
            })
                .filter((r) => r.url);
            rows.sort((a, b) => b.impressions - a.impressions);
            return rows.slice(0, limit);
        }
        catch {
            return [];
        }
    }
    router.post('/seo/audit', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, WRITE_ROLES);
            if (roleErr)
                return roleErr;
            if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
                return modelNotAvailable('SeoAuditRun');
            }
            const ip = clientIp(request);
            const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
            if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
                return errorResponse('Too many audits. Please try again later.', 429);
            }
            const body = (await request.json().catch(() => ({})));
            const scope = body?.scope ?? 'full';
            const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
            const run = await runAndPersistAudit(db(), {
                scope,
                startedById: auth.session.userId,
                collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
            });
            try {
                await logEvent({
                    event: 'settings_changed',
                    userId: auth.session.userId,
                    details: { action: 'seo_audit', auditRunId: run.id, scope },
                });
            }
            catch {
                /* audit-log failures never block */
            }
            return json({ data: run });
        }
        catch (err) {
            return internalError(err, 'seo/audit');
        }
    });
    router.get('/seo/audits', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoAuditRun'))
                return json({ data: { runs: [] } });
            const runs = await db().seoAuditRun.findMany({
                orderBy: { startedAt: 'desc' },
                take: 50,
            });
            return json({ data: { runs } });
        }
        catch (err) {
            return internalError(err, 'seo/audits');
        }
    });
    router.get('/seo/audits/:id', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoAuditRun'))
                return modelNotAvailable('SeoAuditRun');
            const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
            if (!run)
                return errorResponse('Audit run not found', 404);
            return json({ data: run });
        }
        catch (err) {
            return internalError(err, 'seo/audits/:id');
        }
    });
    router.get('/seo/issues', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue'))
                return json({ data: { issues: [] } });
            const url = new URL(request.url);
            const where = {};
            const severity = url.searchParams.get('severity');
            const category = url.searchParams.get('category');
            const status = url.searchParams.get('status');
            const auditRunId = url.searchParams.get('auditRunId');
            if (severity)
                where.severity = severity;
            if (category)
                where.category = category;
            if (status)
                where.status = status;
            else
                where.status = { in: ['open', 'recurring'] };
            if (auditRunId)
                where.auditRunId = auditRunId;
            const issues = await db().seoIssue.findMany({
                where,
                orderBy: { lastDetectedAt: 'desc' },
                take: 500,
            });
            return json({ data: { issues: sortIssuesBySeverity(issues).map(seoIssueToApi) } });
        }
        catch (err) {
            return internalError(err, 'seo/issues');
        }
    });
    router.get('/seo/issues/export-brief', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue')) {
                const { buildSeoFixBriefMarkdown, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
                return json({
                    data: {
                        markdown: buildSeoFixBriefMarkdown({ issues: [], manualOnly: true }),
                        filename: fixBriefFilename(),
                        issueCount: 0,
                    },
                });
            }
            const url = new URL(request.url);
            const severity = url.searchParams.get('severity');
            const status = url.searchParams.get('status');
            const auditRunId = url.searchParams.get('auditRunId');
            const manualOnly = url.searchParams.get('manualOnly') !== 'false';
            const includeContentBriefs = url.searchParams.get('includeContentBriefs') !== 'false';
            const includeContentResearch = url.searchParams.get('includeContentResearch') !== 'false';
            const where = {};
            if (severity)
                where.severity = severity;
            if (status)
                where.status = status;
            else
                where.status = { in: ['open', 'recurring'] };
            if (auditRunId)
                where.auditRunId = auditRunId;
            const issues = sortIssuesBySeverity(await db().seoIssue.findMany({
                where,
                orderBy: { lastDetectedAt: 'desc' },
                take: 500,
            }));
            let runAuditDate = null;
            let runId = auditRunId;
            if (auditRunId && hasModel(db(), 'seoAuditRun')) {
                const run = await db().seoAuditRun.findUnique({ where: { id: auditRunId } });
                runAuditDate = run?.finishedAt ?? run?.startedAt ?? null;
            }
            else if (hasModel(db(), 'seoAuditRun')) {
                const lastRun = await db().seoAuditRun.findFirst({
                    where: { status: 'COMPLETED' },
                    orderBy: { startedAt: 'desc' },
                });
                runId = lastRun?.id ?? null;
                runAuditDate = lastRun?.finishedAt ?? lastRun?.startedAt ?? null;
            }
            const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
            const overrides = await getSeoOverrides(db()).catch(() => null);
            const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
            const siteSeo = mergedConfig?.seo;
            const { buildSeoFixBriefMarkdown, filterFixBriefIssues, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
            const entityIds = [
                ...new Set(issues
                    .map((i) => i.entityId)
                    .filter((id) => typeof id === 'string' && id.length > 0)),
            ];
            const updatedAtByEntity = new Map();
            if ((includeContentBriefs || includeContentResearch) && entityIds.length > 0) {
                const docs = await db().document.findMany({
                    where: { id: { in: entityIds }, deletedAt: null },
                    select: { id: true, updatedAt: true },
                });
                for (const doc of docs) {
                    updatedAtByEntity.set(doc.id, doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt));
                }
            }
            const { contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
            const { contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
            const briefIssues = issues.map((i) => {
                let contentBrief = null;
                let contentResearch = null;
                if (i.entityId) {
                    const updatedAt = updatedAtByEntity.get(i.entityId);
                    if (updatedAt) {
                        if (includeContentBriefs) {
                            contentBrief = contentBriefFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
                        }
                        if (includeContentResearch) {
                            contentResearch = contentResearchFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
                        }
                    }
                }
                return {
                    id: i.id,
                    type: i.type,
                    title: i.title,
                    description: i.description ?? null,
                    impact: i.impact ?? null,
                    recommendation: i.recommendation ?? null,
                    severity: i.severity,
                    category: i.category,
                    status: i.status,
                    entityType: i.entityType ?? null,
                    entityId: i.entityId ?? null,
                    entityTitle: i.entityTitle ?? null,
                    url: i.url ?? null,
                    fixActionType: i.fixActionType ?? null,
                    fixable: i.fixable ?? false,
                    contentBrief,
                    contentResearch,
                };
            });
            const markdown = buildSeoFixBriefMarkdown({
                siteName: siteSeo?.siteName ?? null,
                siteUrl: siteSeo?.siteUrl ?? null,
                auditRunId: runId,
                auditDate: runAuditDate,
                issues: briefIssues,
                manualOnly,
            });
            const issueCount = filterFixBriefIssues(briefIssues, manualOnly).length;
            return json({
                data: {
                    markdown,
                    filename: fixBriefFilename(siteSeo?.siteName, runAuditDate ?? undefined),
                    issueCount,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/issues/export-brief');
        }
    });
    router.post('/seo/issues/batch-content-briefs', 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;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-batch-briefs:${auth.session.userId}`))) {
                return errorResponse('AI rate limit reached. Try again in an hour.', 429);
            }
            const body = (await request.json().catch(() => ({})));
            const severity = typeof body?.severity === 'string' ? body.severity : null;
            const status = typeof body?.status === 'string' ? body.status : null;
            const skipExisting = body?.skipExisting !== false;
            const includeResearch = body?.includeResearch === true;
            const competitorUrls = Array.isArray(body?.competitorUrls)
                ? body.competitorUrls.filter((u) => typeof u === 'string')
                : undefined;
            const issueIds = Array.isArray(body?.issueIds)
                ? body.issueIds.filter((id) => typeof id === 'string' && id.length > 0)
                : null;
            const where = {};
            if (severity)
                where.severity = severity;
            if (status)
                where.status = status;
            else
                where.status = { in: ['open', 'recurring'] };
            let issues = await db().seoIssue.findMany({
                where,
                orderBy: { lastDetectedAt: 'desc' },
                take: 500,
            });
            if (issueIds?.length) {
                const idSet = new Set(issueIds);
                issues = issues.filter((i) => idSet.has(i.id));
            }
            const { supportsContentBrief } = await import('../../seo/issue-content-brief.js');
            const eligible = issues.filter((i) => supportsContentBrief(i.type));
            const entityIds = [
                ...new Set(eligible
                    .map((i) => i.entityId)
                    .filter((id) => typeof id === 'string' && id.length > 0)),
            ];
            const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
            const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
            const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
            const overrides = await getSeoOverrides(db()).catch(() => null);
            const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
            const contexts = new Map();
            if (entityIds.length > 0) {
                const docs = await db().document.findMany({
                    where: { id: { in: entityIds }, deletedAt: null },
                    select: {
                        id: true,
                        collection: true,
                        title: true,
                        slug: true,
                        data: true,
                        structuredData: true,
                        plainText: true,
                        publishedAt: true,
                        updatedAt: true,
                    },
                });
                for (const doc of docs) {
                    contexts.set(doc.id, {
                        collection: doc.collection,
                        entity: documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo),
                        updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
                    });
                }
            }
            const { batchGenerateContentBriefs } = await import('../../seo/issue-batch-briefs.js');
            const result = await batchGenerateContentBriefs(eligible.map((i) => ({
                id: i.id,
                type: i.type,
                title: i.title,
                description: i.description,
                recommendation: i.recommendation,
                entityId: i.entityId,
                entityType: i.entityType,
                metadata: i.metadata,
            })), contexts, { db: db(), userId: auth.session.userId, collection: 'pages' }, { skipExisting, includeResearch, competitorUrls });
            return json({ data: result });
        }
        catch (err) {
            return internalError(err, 'seo/issues/batch-content-briefs');
        }
    });
    router.get('/seo/issues/:id/content-brief', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const { supportsContentBrief, contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
            if (!supportsContentBrief(issue.type)) {
                return json({ data: { supported: false, brief: null, stale: false } });
            }
            if (!issue.entityId) {
                return json({ data: { supported: true, brief: null, stale: false } });
            }
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const brief = contentBriefFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
            const raw = asRecord(issue.metadata)?.contentBrief;
            const stale = !!raw &&
                typeof raw === 'object' &&
                !Array.isArray(raw) &&
                typeof raw.fingerprint === 'string' &&
                !brief;
            return json({ data: { supported: true, brief, stale } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/content-brief');
        }
    });
    router.post('/seo/issues/:id/content-brief', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-brief:${auth.session.userId}`))) {
                return errorResponse('AI rate limit reached. Try again in an hour.', 429);
            }
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
            if (!supportsContentBrief(issue.type)) {
                return errorResponse('This issue type does not support content briefs.', 422);
            }
            const result = await generateContentBrief({
                id: issue.id,
                type: issue.type,
                title: issue.title,
                description: issue.description,
                recommendation: issue.recommendation,
                entityId: issue.entityId,
            }, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection });
            if (!result.ok) {
                if (result.code === 'unavailable' || result.code === 'feature_disabled') {
                    return errorResponse(result.message, 503);
                }
                if (result.code === 'rate_limited') {
                    return errorResponse(result.message, 429);
                }
                return errorResponse(result.message, 422);
            }
            const existingMeta = asRecord(issue.metadata);
            await db().seoIssue.update({
                where: { id: issue.id },
                data: { metadata: { ...existingMeta, ...contentBriefToMetadata(result.brief) } },
            });
            return json({ data: { brief: result.brief } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/content-brief');
        }
    });
    router.get('/seo/issues/:id/content-research', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const { supportsContentResearch, contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
            if (!supportsContentResearch(issue.type)) {
                return json({ data: { supported: false, research: null, stale: false } });
            }
            if (!issue.entityId) {
                return json({ data: { supported: true, research: null, stale: false } });
            }
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const research = contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
            const raw = asRecord(issue.metadata)?.contentResearch;
            const stale = !!raw &&
                typeof raw === 'object' &&
                !Array.isArray(raw) &&
                typeof raw.fingerprint === 'string' &&
                !research;
            return json({ data: { supported: true, research, stale } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/content-research');
        }
    });
    router.post('/seo/issues/:id/content-research', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-research:${auth.session.userId}`))) {
                return errorResponse('AI rate limit reached. Try again in an hour.', 429);
            }
            const body = (await request.json().catch(() => ({})));
            const competitorUrls = Array.isArray(body?.competitorUrls)
                ? body.competitorUrls.filter((u) => typeof u === 'string')
                : undefined;
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const { generateContentResearch, contentResearchToMetadata, supportsContentResearch } = await import('../../seo/issue-content-research.js');
            if (!supportsContentResearch(issue.type)) {
                return errorResponse('This issue type does not support topic research.', 422);
            }
            const result = await generateContentResearch({
                id: issue.id,
                type: issue.type,
                title: issue.title,
                description: issue.description,
                recommendation: issue.recommendation,
                entityId: issue.entityId,
            }, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection }, { competitorUrls });
            if (!result.ok) {
                if (result.code === 'unavailable' || result.code === 'feature_disabled') {
                    return errorResponse(result.message, 503);
                }
                if (result.code === 'rate_limited') {
                    return errorResponse(result.message, 429);
                }
                return errorResponse(result.message, 422);
            }
            const existingMeta = asRecord(issue.metadata);
            await db().seoIssue.update({
                where: { id: issue.id },
                data: { metadata: { ...existingMeta, ...contentResearchToMetadata(result.research) } },
            });
            return json({ data: { research: result.research } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/content-research');
        }
    });
    router.post('/seo/issues/:id/research-and-brief', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (roleErr)
                return roleErr;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-research-brief:${auth.session.userId}`))) {
                return errorResponse('AI rate limit reached. Try again in an hour.', 429);
            }
            const body = (await request.json().catch(() => ({})));
            const competitorUrls = Array.isArray(body?.competitorUrls)
                ? body.competitorUrls.filter((u) => typeof u === 'string')
                : undefined;
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const { generateContentResearch, contentResearchToMetadata, supportsContentResearch, contentResearchFromMetadata, } = await import('../../seo/issue-content-research.js');
            const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
            if (!supportsContentBrief(issue.type)) {
                return errorResponse('This issue type does not support content briefs.', 422);
            }
            const issueInput = {
                id: issue.id,
                type: issue.type,
                title: issue.title,
                description: issue.description,
                recommendation: issue.recommendation,
                entityId: issue.entityId,
            };
            const briefCtx = { entity: loaded.entity, updatedAt: loaded.updatedAt };
            const deps = { db: db(), userId: auth.session.userId, collection: loaded.collection };
            let research = supportsContentResearch(issue.type)
                ? contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt)
                : null;
            if (supportsContentResearch(issue.type) && !research) {
                const researchResult = await generateContentResearch(issueInput, briefCtx, deps, {
                    competitorUrls,
                });
                if (!researchResult.ok) {
                    if (researchResult.code === 'unavailable' || researchResult.code === 'feature_disabled') {
                        return errorResponse(researchResult.message, 503);
                    }
                    if (researchResult.code === 'rate_limited') {
                        return errorResponse(researchResult.message, 429);
                    }
                    return errorResponse(researchResult.message, 422);
                }
                research = researchResult.research;
            }
            const briefResult = await generateContentBrief(issueInput, briefCtx, deps, { research });
            if (!briefResult.ok) {
                if (briefResult.code === 'unavailable' || briefResult.code === 'feature_disabled') {
                    return errorResponse(briefResult.message, 503);
                }
                if (briefResult.code === 'rate_limited') {
                    return errorResponse(briefResult.message, 429);
                }
                return errorResponse(briefResult.message, 422);
            }
            const existingMeta = asRecord(issue.metadata);
            const metadata = {
                ...existingMeta,
                ...contentBriefToMetadata(briefResult.brief),
                ...(research ? contentResearchToMetadata(research) : {}),
            };
            await db().seoIssue.update({
                where: { id: issue.id },
                data: { metadata },
            });
            return json({ data: { research, brief: briefResult.brief } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/research-and-brief');
        }
    });
    router.get('/seo/issues/:id', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            return json({ data: seoIssueToApi(issue) });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id');
        }
    });
    async function setIssueStatus(request, issueId, status) {
        const auth = await requireAuth(request);
        if (auth.error)
            return auth.error;
        const roleErr = requireRole(auth.session.role, WRITE_ROLES);
        if (roleErr)
            return roleErr;
        if (!hasModel(db(), 'seoIssue'))
            return modelNotAvailable('SeoIssue');
        const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
        if (!existing)
            return errorResponse('Issue not found', 404);
        const issue = await db().seoIssue.update({
            where: { id: issueId },
            data: {
                status,
                resolvedAt: status === 'resolved' ? new Date() : null,
                resolvedById: status === 'resolved' ? auth.session.userId : null,
            },
        });
        try {
            await logEvent({
                event: 'settings_changed',
                userId: auth.session.userId,
                details: { action: 'seo_issue_status', issueId, status },
            });
        }
        catch {
            /* noop */
        }
        return json({ data: seoIssueToApi(issue) });
    }
    router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
    router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
    router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
    /** Load the audit entity + collection for an issue's linked document. */
    async function loadIssueFixContext(issue) {
        if (!issue.entityId) {
            return { error: errorResponse('This issue is not linked to a content item.', 422) };
        }
        const doc = await db().document.findFirst({
            where: { id: issue.entityId, deletedAt: null },
            select: {
                id: true,
                collection: true,
                title: true,
                slug: true,
                data: true,
                structuredData: true,
                plainText: true,
                publishedAt: true,
                updatedAt: true,
            },
        });
        if (!doc)
            return { error: errorResponse('Content not found', 404) };
        const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
        const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
        const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
        const overrides = await getSeoOverrides(db()).catch(() => null);
        const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
        const entity = documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo);
        if (issue.entityType && entity.entityType !== issue.entityType) {
            return { error: errorResponse('Issue entity type does not match the linked document.', 409) };
        }
        return {
            collection: doc.collection,
            entity,
            updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
            siteSeo: mergedConfig?.seo,
        };
    }
    router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const issueInput = {
                type: issue.type,
                title: issue.title,
                recommendation: issue.recommendation,
                entityType: issue.entityType,
                entityId: issue.entityId,
            };
            const fixCtx = {
                entity: loaded.entity,
                siteSeo: loaded.siteSeo,
                updatedAt: loaded.updatedAt,
            };
            let aiDeps;
            const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
            if (!adminErr &&
                (await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
                aiDeps = {
                    db: db(),
                    userId: auth.session.userId,
                    collection: loaded.collection,
                };
            }
            const { suggestIssueFix } = await import('../../seo/issue-fix.js');
            const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
            const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
            const existingMeta = asRecord(issue.metadata);
            const nextMetadata = result.cacheForApply
                ? { ...existingMeta, ...pendingFixToMetadata(result.suggestion) }
                : { ...existingMeta, pendingFix: null };
            await db().seoIssue.update({
                where: { id: issue.id },
                data: { metadata: nextMetadata },
            });
            return json({ data: { suggestion: result.suggestion } });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/suggest-fix');
        }
    });
    router.post('/seo/issues/:id/apply-fix', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, WRITE_ROLES);
            if (roleErr)
                return roleErr;
            if (!hasModel(db(), 'seoIssue'))
                return modelNotAvailable('SeoIssue');
            const body = (await request.json().catch(() => ({})));
            const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
            if (!issue)
                return errorResponse('Issue not found', 404);
            if (issue.status === 'ignored') {
                return errorResponse('Cannot apply a fix to an ignored issue. Reopen it first.', 409);
            }
            const loaded = await loadIssueFixContext(issue);
            if ('error' in loaded)
                return loaded.error;
            const issueInput = {
                type: issue.type,
                title: issue.title,
                recommendation: issue.recommendation,
                entityType: issue.entityType,
                entityId: issue.entityId,
            };
            const fixCtx = {
                entity: loaded.entity,
                siteSeo: loaded.siteSeo,
                updatedAt: loaded.updatedAt,
            };
            const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
            const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
            const deterministic = suggestDeterministicFix(issueInput, fixCtx);
            let suggestion = deterministic;
            if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
                const cached = pendingFixFromMetadata(issue.metadata);
                const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
                if (!cached || !cached.autoFixable) {
                    return errorResponse('This issue does not support an automatic fix.', 422);
                }
                if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
                    return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
                }
                if (cached.fingerprint !== liveFingerprint) {
                    return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
                }
                suggestion = {
                    autoFixable: true,
                    source: 'ai',
                    fingerprint: cached.fingerprint,
                    patch: cached.patch,
                    changes: cached.changes,
                    justification: cached.justification,
                };
            }
            else if (body?.fingerprint && body.fingerprint !== suggestion.fingerprint) {
                return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
            }
            if (!suggestion.autoFixable || Object.keys(suggestion.patch).length === 0) {
                return errorResponse('This issue does not support an automatic fix.', 422);
            }
            const ctx = buildActionContext(auth.session, db());
            try {
                await updateDocumentSeoFields(loaded.collection, loaded.entity.entityId, suggestion.patch, ctx);
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : '';
                if (msg.includes('Access denied'))
                    return errorResponse('Insufficient permissions', 403);
                if (msg.includes('not found'))
                    return errorResponse('Document not found', 404);
                throw err;
            }
            const existingMeta = asRecord(issue.metadata);
            const resolved = await db().seoIssue.update({
                where: { id: issue.id },
                data: {
                    status: 'resolved',
                    resolvedAt: new Date(),
                    resolvedById: auth.session.userId,
                    metadata: { ...existingMeta, pendingFix: null },
                },
            });
            try {
                await logEvent({
                    event: 'document_updated',
                    userId: auth.session.userId,
                    details: {
                        action: 'seo_issue_fix_applied',
                        issueId: issue.id,
                        issueType: issue.type,
                        entityId: loaded.entity.entityId,
                        fields: Object.keys(suggestion.patch),
                        source: suggestion.source ?? 'deterministic',
                    },
                });
            }
            catch {
                /* noop */
            }
            return json({
                data: {
                    issue: seoIssueToApi(resolved),
                    applied: suggestion.changes,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/issues/:id/apply-fix');
        }
    });
    router.get('/seo/overview', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const { resolveSeoModuleSettings, getSeoOverrides } = await import('../../seo/config-store.js');
            const { buildQuickWins } = await import('../../seo/audit-engine.js');
            const { gradeForSeoScore } = await import('../../seo/score.js');
            const overrides = await getSeoOverrides(db()).catch(() => null);
            const moduleSettings = resolveSeoModuleSettings(overrides);
            let lastRun = null;
            let openIssues = [];
            if (hasModel(db(), 'seoAuditRun')) {
                lastRun = await db().seoAuditRun.findFirst({
                    where: { status: 'COMPLETED' },
                    orderBy: { startedAt: 'desc' },
                });
            }
            if (hasModel(db(), 'seoIssue')) {
                const rawIssues = await db().seoIssue.findMany({
                    where: { status: { in: ['open', 'recurring'] } },
                    orderBy: { lastDetectedAt: 'desc' },
                    take: 200,
                });
                openIssues = sortIssuesBySeverity(rawIssues);
            }
            const records = await buildSeoContentRecords().catch(() => []);
            const indexable = records.filter((r) => !r.noindex).length;
            const perf = await loadSearchPerformance(10);
            const summary = {
                total: openIssues.length,
                critical: openIssues.filter((i) => i.severity === 'critical').length,
                warning: openIssues.filter((i) => i.severity === 'warning').length,
                info: openIssues.filter((i) => i.severity === 'info').length,
            };
            const scoreAfter = lastRun?.siteScoreAfter ?? null;
            const scoreBefore = lastRun?.siteScoreBefore ?? null;
            const breakdown = lastRun?.metadata?.breakdown ?? [];
            const avgPosition = perf.length > 0
                ? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
                : null;
            const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
            const metrics = [
                {
                    label: 'Pages Indexed',
                    value: String(indexable),
                    helper: `${records.length} total pages`,
                    trend: 'flat',
                },
                {
                    label: 'Open Issues',
                    value: String(summary.total),
                    helper: `${summary.critical} critical`,
                    trend: summary.critical > 0 ? 'down' : 'flat',
                },
                {
                    label: 'Avg. Position',
                    value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
                    helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
                    trend: 'flat',
                },
                {
                    label: 'Weekly Impressions',
                    value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
                    helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
                    trend: 'flat',
                },
            ];
            const quickWins = buildQuickWins(openIssues.map((i) => ({
                issueKey: i.issueKey,
                entityType: i.entityType,
                entityId: i.entityId,
                entityTitle: i.entityTitle,
                url: i.url,
                severity: i.severity,
                category: i.category,
                type: i.type,
                title: i.title,
                description: i.description ?? '',
                impact: i.impact ?? 'Low',
                recommendation: i.recommendation ?? '',
                fixable: i.fixable ?? false,
                fixActionType: i.fixActionType ?? null,
                relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
            }))).map((w) => ({
                id: w.id,
                title: w.title,
                explanation: w.explanation,
                impact: w.impact,
                issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
                fixActionType: w.fixActionType,
            }));
            return json({
                data: {
                    siteScore: {
                        score: scoreAfter ?? 0,
                        grade: gradeForSeoScore(scoreAfter ?? 0),
                        delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
                        breakdown,
                    },
                    metrics,
                    issues: openIssues.slice(0, 7).map(seoIssueToApi),
                    issuesSummary: summary,
                    quickWins,
                    searchPerformance: perf,
                    searchConsoleConnected: moduleSettings.searchConsoleConnected,
                    lastAuditRunId: lastRun?.id ?? null,
                },
            });
        }
        catch (err) {
            return internalError(err, 'seo/overview');
        }
    });
    router.get('/seo/search-performance', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const url = new URL(request.url);
            const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
            const rows = await loadSearchPerformance(limit);
            return json({ data: { rows } });
        }
        catch (err) {
            return internalError(err, 'seo/search-performance');
        }
    });
    router.get('/seo/content', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const records = await buildSeoContentRecords();
            return json({ data: { records } });
        }
        catch (err) {
            return internalError(err, 'seo/content');
        }
    });
    router.get('/seo/content/:entityType/:entityId', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const records = await buildSeoContentRecords();
            const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
            if (!record)
                return errorResponse('Content record not found', 404);
            // Overlay the real llms.txt curation values from the stored document
            // (the audit-entity pipeline that builds the base record doesn't carry
            // these convention keys).
            const doc = await db().document.findFirst({
                where: { id: params.entityId, deletedAt: null },
                select: { data: true },
            });
            const data = asRecord(doc?.data);
            const includeRaw = String(data.llmsInclude ?? '').toUpperCase();
            const merged = {
                ...record,
                llmsInclude: includeRaw === 'ALWAYS' || includeRaw === 'NEVER'
                    ? includeRaw
                    : 'AUTO',
                llmsDescription: typeof data.llmsDescription === 'string' ? data.llmsDescription : null,
                llmsSection: typeof data.llmsSection === 'string' ? data.llmsSection : null,
                llmsOptional: data.llmsOptional === true,
            };
            return json({ data: merged });
        }
        catch (err) {
            return internalError(err, 'seo/content/:id');
        }
    });
    router.put('/seo/content/:entityType/:entityId', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const roleErr = requireRole(auth.session.role, WRITE_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 doc = await db().document.findFirst({
                where: { id: params.entityId, deletedAt: null },
                select: { id: true, collection: true },
            });
            if (!doc)
                return errorResponse('Document not found', 404);
            // String SEO fields: payload key -> stored data key, each with a max
            // length so we never persist unbounded/megabyte values into the content
            // JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
            const TEXT_FIELDS = {
                metaTitle: { target: 'metaTitle', max: 300 },
                metaDescription: { target: 'metaDescription', max: 1000 },
                canonicalUrl: { target: 'canonical', max: 2000 },
                focusKeyword: { target: 'focusKeyword', max: 200 },
                keyTakeaway: { target: 'keyTakeaway', max: 500 },
                ogTitle: { target: 'ogTitle', max: 300 },
                ogDescription: { target: 'ogDescription', max: 1000 },
                ogImage: { target: 'ogImage', max: 2000 },
                twitterTitle: { target: 'twitterTitle', max: 300 },
                twitterDescription: { target: 'twitterDescription', max: 1000 },
                twitterImage: { target: 'twitterImage', max: 2000 },
                structuredDataType: { target: 'structuredDataType', max: 100 },
                schemaType: { target: 'schemaType', max: 100 },
                llmsDescription: { target: 'llmsDescription', max: 500 },
                llmsSection: { target: 'llmsSection', max: 100 },
            };
            const patch = {};
            for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
                if (!(key in body))
                    continue;
                const value = body[key];
                if (value === null || value === '') {
                    patch[target] = '';
                    continue;
                }
                if (typeof value !== 'string') {
                    return errorResponse(`Field "${key}" must be a string`, 400);
                }
                if (value.length > max) {
                    return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
                }
                patch[target] = value;
            }
            if ('noindex' in body)
                patch.noindex = body.noindex === true;
            if ('nofollow' in body)
                patch.nofollow = body.nofollow === true;
            if ('llmsOptional' in body)
                patch.llmsOptional = body.llmsOptional === true;
            if ('llmsInclude' in body) {
                const v = String(body.llmsInclude ?? '').toUpperCase();
                if (v !== 'AUTO' && v !== 'ALWAYS' && v !== 'NEVER') {
                    return errorResponse('llmsInclude must be one of AUTO, ALWAYS, NEVER', 400);
                }
                // AUTO is the default — store empty so the field falls back cleanly.
                patch.llmsInclude = v === 'AUTO' ? '' : v;
            }
            if ('llmsPriority' in body) {
                const n = body.llmsPriority;
                if (n !== null && typeof n !== 'number') {
                    return errorResponse('llmsPriority must be a number', 400);
                }
                patch.llmsPriority = n ?? 0;
            }
            if ('structuredData' in body) {
                const sd = body.structuredData;
                if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
                    return errorResponse('structuredData must be a JSON object', 400);
                }
                if (sd && JSON.stringify(sd).length > 50_000) {
                    return errorResponse('structuredData payload is too large', 400);
                }
                patch.structuredData = sd;
            }
            if (Object.keys(patch).length === 0) {
                return errorResponse('No recognized SEO fields in request body', 400);
            }
            if ('structuredDataType' in patch || 'schemaType' in patch) {
                Object.assign(patch, syncSchemaTypeFields(patch));
            }
            // Route through the data layer so the edit gets per-collection access
            // control, a Version snapshot, search re-indexing and the afterUpdate
            // hook — never write document.data directly. (updateDocumentSeoFields
            // preserves the undeclared SEO convention keys that the generic
            // updateDocument field-access layer would strip.)
            const ctx = buildActionContext(auth.session, db());
            try {
                await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : '';
                if (msg.includes('Access denied'))
                    return errorResponse('Insufficient permissions', 403);
                if (msg.includes('not found'))
                    return errorResponse('Document not found', 404);
                throw err;
            }
            try {
                await logEvent({
                    event: 'document_updated',
                    userId: auth.session.userId,
                    details: { action: 'seo_fields_update', documentId: doc.id },
                });
            }
            catch {
                /* noop */
            }
            const records = await buildSeoContentRecords();
            const record = records.find((r) => r.entityId === doc.id);
            return json({ data: record ?? { ok: true } });
        }
        catch (err) {
            return internalError(err, 'seo/content PUT');
        }
    });
    // ---------------------------------------------------------------------------
    // SEO analysis, readability, internal links, schema, meta
    // ---------------------------------------------------------------------------
    router.get('/seo/analysis/: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 { analyzeContent } = await import('../../seo/analysis.js');
            const data = doc.data || {};
            const result = analyzeContent({
                title: doc.title || data.title || '',
                slug: doc.slug || data.slug || '',
                content: data.content || data.body || '',
                metaTitle: data.metaTitle || data.seoTitle,
                metaDescription: data.metaDescription || data.seoDescription,
                focusKeyphrase: data.focusKeyphrase || data.focusKeyword,
                canonical: data.canonical || data.canonicalUrl,
                ogTitle: data.ogTitle,
                ogDescription: data.ogDescription,
                ogImage: data.ogImage,
                isCornerstone: data.isCornerstone,
            });
            return new Response(JSON.stringify(result), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }
        catch (err) {
            return internalError(err, 'seo/analysis');
        }
    });
    router.get('/seo/readability/: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 { calculateReadability, stripHtmlTags } = await import('../../seo/analysis.js');
            const data = doc.data || {};
            const text = stripHtmlTags(data.content || data.body || '');
            const result = calculateReadability(text);
            return new Response(JSON.stringify(result), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }
        catch (err) {
            return internalError(err, 'seo/readability');
        }
    });
    router.get('/seo/internal-links/: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 data = doc.data || {};
            const title = doc.title || data.title || '';
            const keywords = title
                .split(/\s+/)
                .filter((w) => w.length > 3)
                .slice(0, 5);
            if (keywords.length === 0) {
                return new Response(JSON.stringify({ suggestions: [] }), {
                    status: 200,
                    headers: { 'Content-Type': 'application/json' },
                });
            }
            const suggestions = await db().document.findMany({
                where: {
                    id: { not: params.documentId },
                    status: 'PUBLISHED',
                    deletedAt: null,
                    OR: keywords.map((kw) => ({
                        title: { contains: kw, mode: 'insensitive' },
                    })),
                },
                select: { id: true, title: true, slug: true, collection: true },
                take: 10,
                orderBy: { updatedAt: 'desc' },
            });
            return new Response(JSON.stringify({ suggestions }), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }
        catch (err) {
            return internalError(err, 'seo/internal-links');
        }
    });
    router.get('/llms.txt', async (request) => {
        try {
            // Public surface: must exclude soft-deleted docs and internal/system
            // collections (e.g. `__seo_config__`). Over-fetch then filter so the
            // 50-page cap still lands on real content after exclusion.
            const { isInternalCollection } = await import('../../seo/audit-runner.js');
            const { siteUrlFromRequest } = await import('./seo-public.js');
            const cfg = getActuateConfig();
            const llmsCfg = cfg?.seo?.llms;
            const base = siteUrlFromRequest(request, cfg);
            // Operators can disable the file entirely (it's optionality, not a
            // guaranteed traffic channel).
            if (llmsCfg?.disabled) {
                return new Response('Not found', { status: 404 });
            }
            const maxEntries = llmsCfg?.maxEntries ?? 50;
            const maxPerSection = llmsCfg?.maxPerSection ?? 25;
            // Restrict AUTO selection to the configured high-value collections when
            // set, so blog archives don't flood the curated guide.
            const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
                ? new Set(llmsCfg.eligibleCollections)
                : null;
            const rawDocs = await db().document.findMany({
                where: { status: 'PUBLISHED', deletedAt: null },
                select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
                orderBy: { updatedAt: 'desc' },
                take: 500,
            });
            // Per-doc curation + privacy boundary (noindex exclusion) live in the
            // shared selector so `/llms.txt` and `/llms-full.txt` can never diverge.
            const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
            const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible });
            const { generateLlmsTxt } = await import('../../seo/llms-txt.js');
            const pages = selected.map((d) => toLlmsPage(d, { base, urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix }));
            const txt = generateLlmsTxt({
                siteName: cfg?.seo?.siteName || 'Actuate CMS',
                siteUrl: base,
                siteDescription: llmsCfg?.summary ||
                    cfg?.seo?.defaultMetaDescription ||
                    'Content managed by Actuate CMS',
                intro: llmsCfg?.intro,
                maxPages: maxEntries,
                maxPerSection,
            }, pages);
            // Opt-in honeypot: append a unique trap link. No human reads llms.txt, so
            // a fetch of this URL is an automated reader — recorded under the
            // "honeypot" surface in the AI-crawler panel.
            let body = txt;
            if (llmsCfg?.honeypot) {
                const trapUrl = `${base.replace(/\/$/, '')}/api/cms/ai-crawlers/trap`;
                body += `\n## Reference\n\n- [Complete content index](${trapUrl}): Full machine-readable content map.\n`;
            }
            // Measurement: if a known AI crawler fetched the index, count it.
            await recordAiCrawlerFromRequest(db(), request, 'llms');
            return new Response(body, {
                status: 200,
                headers: {
                    'Content-Type': 'text/plain; charset=utf-8',
                    // The file itself is a machine-facing index, not a page to rank.
                    'X-Robots-Tag': 'noindex',
                },
            });
        }
        catch (err) {
            return internalError(err, 'llms.txt');
        }
    });
    // `/llms-full.txt` — same curated selection as `/llms.txt`, but inlines each
    // page's content rendered to clean Markdown so an agent can read the actual
    // body without fetching every URL. Opt-in (these files get large) and bounded
    // by a byte budget. noindex + never in the sitemap; HTML stays canonical.
    router.get('/llms-full.txt', async (request) => {
        try {
            const { isInternalCollection } = await import('../../seo/audit-runner.js');
            const { siteUrlFromRequest } = await import('./seo-public.js');
            const cfg = getActuateConfig();
            const llmsCfg = cfg?.seo?.llms;
            const base = siteUrlFromRequest(request, cfg);
            // Gated independently from `/llms.txt`: must be both enabled overall and
            // have the full variant turned on.
            if (llmsCfg?.disabled || !llmsCfg?.fullEnabled) {
                return new Response('Not found', { status: 404 });
            }
            const maxEntries = llmsCfg?.maxEntries ?? 50;
            const maxBytes = llmsCfg?.maxFullBytes ?? 2_000_000;
            const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
                ? new Set(llmsCfg.eligibleCollections)
                : null;
            const rawDocs = await db().document.findMany({
                where: { status: 'PUBLISHED', deletedAt: null },
                select: {
                    title: true,
                    slug: true,
                    collection: true,
                    updatedAt: true,
                    data: true,
                    plainText: true,
                },
                orderBy: { updatedAt: 'desc' },
                take: 500,
            });
            const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
            const { documentToMarkdown } = await import('../../seo/content-markdown.js');
            const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible }).slice(0, maxEntries);
            const header = [
                `# ${cfg?.seo?.siteName || 'Actuate CMS'}`,
                '',
                `> ${llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Full content export for AI agents.'}`,
                '',
            ].join('\n');
            const chunks = [header];
            let bytes = Buffer.byteLength(header, 'utf8');
            let truncated = false;
            for (const d of selected) {
                const page = toLlmsPage(d, {
                    base,
                    urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
                });
                const md = documentToMarkdown({
                    title: d.title,
                    data: d.data,
                    plainText: d.plainText,
                });
                // Each entry is delimited by a horizontal rule and carries its canonical
                // URL so the inlined content stays traceable back to the live page.
                const block = `\n---\n\nURL: ${page.url}\n\n${md}\n`;
                const blockBytes = Buffer.byteLength(block, 'utf8');
                if (bytes + blockBytes > maxBytes) {
                    truncated = true;
                    break;
                }
                chunks.push(block);
                bytes += blockBytes;
            }
            if (truncated) {
                chunks.push('\n---\n\n> Output truncated at the configured size limit.\n');
            }
            await recordAiCrawlerFromRequest(db(), request, 'llms-full');
            return new Response(chunks.join(''), {
                status: 200,
                headers: {
                    'Content-Type': 'text/plain; charset=utf-8',
                    'X-Robots-Tag': 'noindex',
                },
            });
        }
        catch (err) {
            return internalError(err, 'llms-full.txt');
        }
    });
    // Per-page Markdown: `GET /md?path=/about` returns a clean Markdown rendering
    // of a single published page so an agent can fetch the content, not just the
    // link. Fresh installs expose this at clean `/{path}.md` URLs via a scaffolded
    // rewrite. CRITICAL: always noindex, never in the sitemap — the HTML page
    // stays canonical (per-page `.md` is the most common duplicate-content trap).
    router.get('/md', async (request) => {
        try {
            const cfg = getActuateConfig();
            const url = new URL(request.url);
            const pathParam = url.searchParams.get('path');
            if (!pathParam) {
                return errorResponse('Missing required "path" query parameter', 400);
            }
            // Part of the AI/agent layer — honor the same master switch as llms.txt.
            if (cfg?.seo?.llms?.disabled) {
                return new Response('Not found', { status: 404 });
            }
            const { matchPathToCollection } = await import('../../seo/resolve-path.js');
            const match = matchPathToCollection(cfg, pathParam);
            if (!match) {
                return new Response('Not found', { status: 404 });
            }
            const slugFilter = match.isRoot
                ? {
                    OR: [
                        { data: { path: ['slug'], equals: 'home' } },
                        { data: { path: ['slug'], equals: 'index' } },
                        { slug: 'home' },
                        { slug: 'index' },
                    ],
                }
                : {
                    OR: [{ data: { path: ['slug'], equals: match.slug } }, { slug: match.slug }],
                };
            const doc = await db().document.findFirst({
                where: {
                    collection: match.collection,
                    deletedAt: null,
                    status: 'PUBLISHED',
                    ...slugFilter,
                },
                select: { title: true, data: true, plainText: true },
            });
            if (!doc) {
                return new Response('Not found', { status: 404 });
            }
            const data = doc.data || {};
            // The selection filter is the privacy boundary: noindexed or explicitly
            // excluded pages must not be fetchable as Markdown either.
            const { isLlmsNoindex, readLlmsInclude } = await import('../../seo/llms-select.js');
            if (isLlmsNoindex(data) || readLlmsInclude(data) === 'NEVER') {
                return new Response('Not found', { status: 404 });
            }
            const { documentToMarkdown } = await import('../../seo/content-markdown.js');
            const md = documentToMarkdown({ title: doc.title, data, plainText: doc.plainText });
            await recordAiCrawlerFromRequest(db(), request, 'md');
            return new Response(md, {
                status: 200,
                headers: {
                    'Content-Type': 'text/markdown; charset=utf-8',
                    'X-Robots-Tag': 'noindex',
                },
            });
        }
        catch (err) {
            return internalError(err, 'md');
        }
    });
    // Honeypot: a unique trap URL appended to llms.txt only when
    // `seo.llms.honeypot` is enabled. No human visits it, so any fetch is an
    // automated reader that followed a link from the file. Always records (even
    // if the flag was later turned off, a cached llms.txt may still point here)
    // and returns a tiny noindex body.
    router.get('/ai-crawlers/trap', async (request) => {
        try {
            const detected = detectAiBot(request.headers.get('user-agent'));
            await recordAiCrawlerHit(db(), {
                bot: detected?.token ?? 'unknown',
                surface: 'honeypot',
                category: detected?.category ?? 'other',
            });
            return new Response('This endpoint exists to detect automated indexers.\n', {
                status: 200,
                headers: {
                    'Content-Type': 'text/plain; charset=utf-8',
                    'X-Robots-Tag': 'noindex',
                },
            });
        }
        catch (err) {
            return internalError(err, 'ai-crawlers/trap');
        }
    });
    // Admin: aggregated AI-crawler activity for the SEO → Technical panel.
    // Any authenticated admin may read it (analytics, not a mutation).
    router.get('/ai-crawlers', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            if (!hasModel(db(), 'aiCrawlerHit')) {
                return json({
                    data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
                });
            }
            const rows = await db().aiCrawlerHit.findMany({
                orderBy: { lastSeenAt: 'desc' },
                take: 500,
            });
            const totalHits = rows.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
            const honeypotHits = rows
                .filter((r) => r.surface === 'honeypot')
                .reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
            const uniqueBots = new Set(rows.map((r) => r.bot)).size;
            return json({
                data: {
                    enabled: true,
                    rows: rows.map((r) => ({
                        bot: r.bot,
                        surface: r.surface,
                        category: r.category,
                        hits: Number(r.hits ?? 0),
                        firstSeenAt: r.firstSeenAt,
                        lastSeenAt: r.lastSeenAt,
                    })),
                    totals: { hits: totalHits, bots: uniqueBots, honeypot: honeypotHits },
                },
            });
        }
        catch (err) {
            return internalError(err, 'ai-crawlers');
        }
    });
}
//# sourceMappingURL=seo.js.map