/**
 * Content health cron — runs `scanDocument` across published
 * documents in chunks, upserts findings into
 * `actuate_content_issues`, and deletes rows whose issue resolved
 * since the previous run.
 *
 * ## Why chunked iteration
 *
 * A single full scan over a 10k-doc corpus does ~10k HTML parses
 * plus up to ~50k internal-link probes (HEAD requests with 5s
 * timeouts). On Vercel that quickly approaches the function
 * wall-time cap. We batch in `BATCH_SIZE` pages and yield to the
 * event loop between batches so a 10-minute cron can make
 * incremental progress even when the corpus is large.
 *
 * ## Why upsert + delete-stale
 *
 * The scanner is the source of truth. After a run, any (docId, type)
 * pair NOT emitted by the scan no longer applies — the editor
 * fixed the issue, the document was unpublished, etc. We track
 * pairs we've seen and DELETE the unseen ones at the end.
 *
 * ## Why we never delete on partial failure
 *
 * If the scan throws mid-run, we abandon the delete step. Better
 * to over-report (a previously-recorded issue may already be
 * resolved) than to silently drop a real issue because the scan
 * crashed before reaching that document.
 */
import { scanDocument, checkInternalLinkReachable, } from './scanner.js';
const DEFAULT_BATCH_SIZE = 50;
const DEFAULT_MAX_DOCUMENTS = 500;
export async function processContentHealthScan(db, options = {}) {
    const maxDocuments = options.maxDocuments ?? DEFAULT_MAX_DOCUMENTS;
    const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
    const linkProbeTimeoutMs = options.linkProbeTimeoutMs ?? 5000;
    // If neither a checker nor a siteOrigin is supplied, the link
    // check is disabled — the scanner still runs the other three
    // checks. Passing `checkLink: null` explicitly is the supported
    // way to opt out (useful in CI / tests).
    const checkLink = options.checkLink === undefined
        ? (url) => checkInternalLinkReachable(url, linkProbeTimeoutMs)
        : options.checkLink;
    const result = {
        scanned: 0,
        inserted: 0,
        updated: 0,
        resolved: 0,
        totals: {
            MISSING_META_DESCRIPTION: 0,
            MISSING_ALT_TEXT: 0,
            BROKEN_INTERNAL_LINK: 0,
            OUTDATED_CONTENT: 0,
        },
        truncated: false,
    };
    if (!db?.document || !db?.contentIssue)
        return result;
    // Track the (docId, type) pairs we've seen this run. After the
    // scan completes, any row in the table NOT in this set is
    // resolved and gets deleted.
    const seen = new Set();
    let cursor;
    while (result.scanned < maxDocuments) {
        const remaining = maxDocuments - result.scanned;
        const take = Math.min(batchSize, remaining);
        const batch = await db.document.findMany({
            where: { deletedAt: null, status: 'PUBLISHED' },
            orderBy: { id: 'asc' },
            take,
            ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
            select: { id: true, data: true, updatedAt: true, publishedAt: true },
        });
        if (batch.length === 0)
            break;
        for (const doc of batch) {
            const issues = await scanDocument({
                id: doc.id,
                data: (doc.data ?? {}),
                updatedAt: doc.updatedAt,
                publishedAt: doc.publishedAt,
            }, {
                checkLink,
                siteOrigin: options.siteOrigin ?? null,
            });
            for (const issue of issues) {
                seen.add(`${doc.id}|${issue.type}`);
                const persisted = await upsertIssue(db, doc.id, issue);
                if (persisted === 'inserted')
                    result.inserted++;
                else if (persisted === 'updated')
                    result.updated++;
                result.totals[issue.type] += issue.count;
            }
            result.scanned++;
        }
        cursor = batch[batch.length - 1]?.id;
        if (batch.length < take)
            break;
        if (result.scanned >= maxDocuments) {
            result.truncated = true;
            break;
        }
    }
    // Resolve no-longer-applicable rows. Done in one DELETE per
    // type so the table stays consistent even on a corpus with
    // 10k+ resolved issues — `IN` clauses on large id lists are
    // far slower than `NOT IN` with a (small) set of currently-seen
    // pairs per type.
    result.resolved = await pruneResolved(db, seen);
    return result;
}
async function upsertIssue(db, documentId, issue) {
    // We can't tell `upsert` to report whether it inserted or
    // updated from a single call, so we attempt to `update` first
    // and fall back to `create` on P2025 (record not found).
    try {
        await db.contentIssue.update({
            where: { documentId_type: { documentId, type: issue.type } },
            data: {
                count: issue.count,
                details: issue.details ?? null,
                lastSeenAt: new Date(),
            },
        });
        return 'updated';
    }
    catch (err) {
        if (err?.code === 'P2025') {
            await db.contentIssue.create({
                data: {
                    documentId,
                    type: issue.type,
                    count: issue.count,
                    details: issue.details ?? undefined,
                },
            });
            return 'inserted';
        }
        throw err;
    }
}
/**
 * Delete rows for (documentId, type) pairs NOT present in `seen`.
 * Implementation: load all current rows in (id, documentId, type)
 * tuples, filter to the unseen ones, and bulk-delete. For tables
 * up to ~100k rows this is faster than building a giant SQL
 * `WHERE NOT (... OR ... OR ...)` clause.
 */
async function pruneResolved(db, seen) {
    const allRows = await db.contentIssue.findMany({
        select: { id: true, documentId: true, type: true },
    });
    const stale = allRows.filter((r) => !seen.has(`${r.documentId}|${r.type}`)).map((r) => r.id);
    if (stale.length === 0)
        return 0;
    // Chunked deleteMany — Postgres caps IN-list size around 32k
    // parameters; 1000 is well under and easy to reason about.
    let deleted = 0;
    for (let i = 0; i < stale.length; i += 1000) {
        const slice = stale.slice(i, i + 1000);
        const r = await db.contentIssue.deleteMany({ where: { id: { in: slice } } });
        deleted += r.count;
    }
    return deleted;
}
//# sourceMappingURL=cron.js.map