/**
 * SEO audit orchestration: gather content from the DB, run the pure analysis
 * engine, and persist a `SeoAuditRun` snapshot plus the live `SeoIssue` set.
 *
 * Issue lifecycle (carried across runs by `issueKey`):
 *   - newly detected            -> status `open`
 *   - detected + was `ignored`  -> stays `ignored`
 *   - detected + was `resolved` -> `recurring`
 *   - detected + open/recurring -> unchanged (lastDetectedAt bumped)
 *   - NOT detected + open/recurring -> auto `resolved` (resolvedAt set)
 *   - NOT detected + ignored/resolved -> unchanged
 *
 * The run row stores the score + counts snapshot, so audit *history* is the
 * sequence of runs while the issue table reflects current state.
 */
import { runSeoAuditAnalysis, } from './audit-engine.js';
import { resolveSeoModuleSettings, getSeoOverrides, applySeoOverrides, SEO_CONFIG_COLLECTION, } from './config-store.js';
import { gatherAuditSignals } from './audit-signals.js';
import { getActuateConfig } from '../config/runtime.js';
import { resolveDocumentUrl, resolveEffectiveCanonicalUrl, resolveEffectiveMeta, } from './page-meta.js';
import { resolveEffectiveSchemaType } from './schema-sync.js';
/** Collections that are never audited (internal/system data). */
const EXCLUDED_COLLECTIONS = new Set([
    SEO_CONFIG_COLLECTION,
    'page-tags',
    'redirects',
    '__seo_config__',
]);
/**
 * True for internal/system collections that must never be exposed on public
 * surfaces (audit, `/llms.txt`, etc.). Covers the explicit exclude list plus
 * any `__`-prefixed convention collection (e.g. `__seo_config__`, `__seo_perf__`).
 */
export function isInternalCollection(collection) {
    return EXCLUDED_COLLECTIONS.has(collection) || String(collection).startsWith('__');
}
/**
 * Derive the set of front-end **content** collections (and each one's page/post
 * type) from the resolved CMS config. These are the only collections the SEO
 * tools (Content SEO list, audit, scan, internal-link candidates) operate on.
 *
 * A collection qualifies only when it is:
 *   - explicitly typed `page` or `post` (i.e. a publicly-rendered content type),
 *   - not hidden from the admin (`admin.hidden`),
 *   - not excluded from the sitemap (collection- or site-level).
 *
 * This deliberately excludes structural/utility collections (navigation menus,
 * footers, tags, templates, redirects) — which have no `type` — as well as any
 * document whose collection isn't declared in the config at all (e.g. benchmark
 * or other ad-hoc rows that only exist in the database). The result is that the
 * SEO surfaces show real front-end pages/posts/landing pages and nothing else.
 */
export function frontEndContentCollectionTypes(cfg) {
    const out = {};
    const config = (cfg ?? {});
    const collections = config.collections ?? {};
    const excluded = new Set(config.seo?.sitemap?.excludeCollections ?? []);
    for (const [slug, col] of Object.entries(collections)) {
        if (col?.type !== 'page' && col?.type !== 'post')
            continue;
        if (col?.admin?.hidden)
            continue;
        if (excluded.has(slug))
            continue;
        if (col?.seo?.excludeFromSitemap)
            continue;
        out[slug] = col.type;
    }
    return out;
}
/**
 * Upper bound on documents loaded into a single audit pass. Audits hold the
 * full entity set in memory and rescore each one, so an unbounded `findMany`
 * is an OOM / latency risk on large instances. Sites past this cap should move
 * to incremental/queued auditing.
 */
const AUDIT_MAX_ENTITIES = 5000;
function asRecord(v) {
    return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
}
function str(v) {
    return typeof v === 'string' && v.length > 0 ? v : null;
}
/** Map a published document row into the audit engine's entity shape. */
export function documentToAuditEntity(doc, collectionTypes, siteSeo) {
    const data = asRecord(doc.data);
    const structured = asRecord(doc.structuredData);
    const slug = str(data.slug) ?? doc.slug ?? doc.id;
    const config = getActuateConfig();
    const collectionDef = config?.collections?.[doc.collection];
    const site = siteSeo ?? config?.seo;
    const siteUrl = site?.siteUrl?.replace(/\/+$/, '') ?? '';
    const url = resolveDocumentUrl({
        siteUrl: '',
        urlPrefix: collectionDef?.urlPrefix,
        slug,
        data,
    });
    const canonicalUrl = resolveEffectiveCanonicalUrl({
        data,
        siteUrl,
        urlPrefix: collectionDef?.urlPrefix,
        slug,
    });
    const content = str(data.body) ??
        str(data.content) ??
        (typeof doc.plainText === 'string' ? doc.plainText : '') ??
        '';
    // Prefer the configured collection type; fall back to the legacy heuristic
    // (only `pages` is a page) when no config map is supplied.
    const entityType = collectionTypes?.[doc.collection] ?? (doc.collection === 'pages' ? 'page' : 'post');
    const structuredDataType = resolveEffectiveSchemaType({
        collection: doc.collection,
        data,
        structuredData: structured,
        collectionDef,
    });
    const effectiveMeta = resolveEffectiveMeta({
        data,
        docTitle: str(data.title) ?? doc.title,
        publishedAt: doc.publishedAt,
        collection: collectionDef,
        collectionSlug: doc.collection,
        site,
        siteUrl,
    });
    return {
        entityType,
        entityId: doc.id,
        title: str(data.title) ?? doc.title ?? 'Untitled',
        url,
        content,
        metaTitle: effectiveMeta.metaTitle,
        metaDescription: effectiveMeta.metaDescription,
        focusKeyword: str(data.focusKeyword) ?? str(data.focusKeyphrase),
        keyTakeaway: str(data.keyTakeaway),
        canonicalUrl,
        ogTitle: str(data.ogTitle),
        ogDescription: str(data.ogDescription),
        ogImage: effectiveMeta.ogImage,
        structuredDataType,
        noindex: effectiveMeta.noindex,
        shouldBeIndexable: true,
        wordCount: typeof data.wordCount === 'number' ? data.wordCount : null,
    };
}
export async function gatherAuditEntities(db, options = {}) {
    const { collectionTypes } = options;
    // When the caller knows the front-end content collections, scope the query to
    // them at the DB level so structural/benchmark rows never even count against
    // the entity cap.
    const where = { deletedAt: null, status: 'PUBLISHED' };
    if (collectionTypes) {
        const slugs = Object.keys(collectionTypes);
        if (slugs.length === 0)
            return [];
        where.collection = { in: slugs };
    }
    const docs = await db.document.findMany({
        where,
        select: {
            id: true,
            collection: true,
            title: true,
            slug: true,
            data: true,
            structuredData: true,
            plainText: true,
            publishedAt: true,
        },
        orderBy: { updatedAt: 'desc' },
        take: AUDIT_MAX_ENTITIES,
    });
    const overrides = await getSeoOverrides(db).catch(() => null);
    const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
    const siteSeo = mergedConfig?.seo;
    return docs
        .filter((d) => (collectionTypes ? true : !isInternalCollection(d.collection)))
        .map((d) => documentToAuditEntity(d, collectionTypes, siteSeo));
}
/**
 * Run an audit and persist the run + issue snapshot. Returns the run summary.
 * Throws if the SEO models aren't available (caller maps to 501).
 */
export async function runAndPersistAudit(db, options = {}) {
    if (!db?.seoAuditRun?.create || !db?.seoIssue?.findMany) {
        throw new Error('SEO models not available');
    }
    const scope = options.scope ?? 'full';
    const overrides = await getSeoOverrides(db).catch(() => null);
    const moduleSettings = resolveSeoModuleSettings(overrides);
    // Refuse to start when another audit is already RUNNING. Two concurrent
    // runs both reconcile the shared SeoIssue table and can create duplicate
    // rows for the same issueKey (it carries an index, not a unique constraint).
    // A run older than the stale window is treated as crashed and ignored.
    const STALE_RUN_MS = 15 * 60 * 1000;
    const inFlight = await db.seoAuditRun
        .findFirst({
        where: { status: 'RUNNING' },
        orderBy: { startedAt: 'desc' },
        select: { startedAt: true },
    })
        .catch(() => null);
    if (inFlight?.startedAt && Date.now() - new Date(inFlight.startedAt).getTime() < STALE_RUN_MS) {
        throw new Error('An audit is already running');
    }
    // Previous run's after-score becomes this run's before-score.
    const prev = await db.seoAuditRun.findFirst({
        where: { status: 'COMPLETED' },
        orderBy: { startedAt: 'desc' },
        select: { siteScoreAfter: true },
    });
    const run = await db.seoAuditRun.create({
        data: {
            status: 'RUNNING',
            scope,
            startedById: options.startedById ?? null,
            siteScoreBefore: prev?.siteScoreAfter ?? null,
        },
    });
    try {
        const entities = await gatherAuditEntities(db, { collectionTypes: options.collectionTypes });
        const siteUrl = getActuateConfig()?.seo?.siteUrl?.replace(/\/+$/, '');
        const crossSignals = await gatherAuditSignals(db, entities, siteUrl);
        const signals = {
            sitemapEnabled: moduleSettings.sitemapEnabled,
            robotsEnabled: moduleSettings.robotsEnabled,
            ...crossSignals,
            ...options.signals,
        };
        const result = runSeoAuditAnalysis(entities, signals);
        await persistIssues(db, run.id, result.issues);
        await db.seoAuditRun.update({
            where: { id: run.id },
            data: {
                status: 'COMPLETED',
                finishedAt: new Date(),
                siteScoreAfter: result.siteScore.score,
                pagesScanned: entities.length,
                issuesFound: result.counts.total,
                criticalCount: result.counts.critical,
                warningCount: result.counts.warning,
                infoCount: result.counts.info,
                metadata: { breakdown: result.siteScore.breakdown },
            },
        });
        return {
            id: run.id,
            status: 'COMPLETED',
            siteScoreAfter: result.siteScore.score,
            issuesFound: result.counts.total,
            criticalCount: result.counts.critical,
            warningCount: result.counts.warning,
            infoCount: result.counts.info,
            pagesScanned: entities.length,
        };
    }
    catch (err) {
        await db.seoAuditRun
            .update({
            where: { id: run.id },
            data: {
                status: 'FAILED',
                finishedAt: new Date(),
                errorMessage: err instanceof Error ? err.message : 'Audit failed',
            },
        })
            .catch(() => { });
        throw err;
    }
}
/** Upsert detected issues and auto-resolve no-longer-detected ones. */
async function persistIssues(db, auditRunId, detected) {
    const existing = await db.seoIssue.findMany({});
    const byKey = new Map(existing.map((i) => [i.issueKey, i]));
    const detectedKeys = new Set(detected.map((d) => d.issueKey));
    const now = new Date();
    for (const issue of detected) {
        const prior = byKey.get(issue.issueKey);
        if (prior) {
            let status = prior.status;
            if (prior.status === 'resolved')
                status = 'recurring';
            // 'ignored' stays ignored; 'open'/'recurring' unchanged.
            await db.seoIssue.update({
                where: { id: prior.id },
                data: {
                    auditRunId,
                    status,
                    lastDetectedAt: now,
                    entityTitle: issue.entityTitle,
                    url: issue.url,
                    severity: issue.severity,
                    category: issue.category,
                    title: issue.title,
                    description: issue.description,
                    impact: issue.impact,
                    recommendation: issue.recommendation,
                    fixable: issue.fixable,
                    fixActionType: issue.fixActionType,
                    relatedEntityIds: issue.relatedEntityIds,
                    resolvedAt: status === 'resolved' ? prior.resolvedAt : null,
                },
            });
        }
        else {
            await db.seoIssue.create({
                data: {
                    auditRunId,
                    issueKey: issue.issueKey,
                    entityType: issue.entityType,
                    entityId: issue.entityId,
                    entityTitle: issue.entityTitle,
                    url: issue.url,
                    severity: issue.severity,
                    category: issue.category,
                    type: issue.type,
                    title: issue.title,
                    description: issue.description,
                    impact: issue.impact,
                    recommendation: issue.recommendation,
                    status: 'open',
                    fixable: issue.fixable,
                    fixActionType: issue.fixActionType,
                    relatedEntityIds: issue.relatedEntityIds,
                    source: 'audit',
                },
            });
        }
    }
    // Auto-resolve open/recurring issues that were not detected this run.
    const toResolve = existing.filter((i) => !detectedKeys.has(i.issueKey) && (i.status === 'open' || i.status === 'recurring'));
    for (const i of toResolve) {
        await db.seoIssue.update({
            where: { id: i.id },
            data: { status: 'resolved', resolvedAt: now },
        });
    }
}
//# sourceMappingURL=audit-runner.js.map