/**
 * Content health scanner — produces the issue rows that power the
 * dashboard "Content Health" card.
 *
 * ## Why a separate module from the cron / endpoint
 *
 * The scanner is pure logic — give it a document, get back an array
 * of issues. The cron drives it across the corpus and persists the
 * results; the endpoint reads the persisted results. Keeping the
 * scanner free of I/O dependencies makes it trivially testable
 * (~10 µs per call, no DB) and lets future callers (CI checks, a
 * pre-publish hook, etc.) reuse it without dragging in cron
 * infrastructure.
 *
 * ## Why HEAD probes for the link checker
 *
 * Internal links 404 in two flavors: a hard 404 from the framework
 * (page genuinely doesn't exist) and a soft 404 where the framework
 * returns 200 with a "not found" template. We only catch the first.
 * That's fine — the dashboard's job is to surface the high-confidence
 * issues, not chase ambiguous soft-404 heuristics. The scanner can
 * be extended with response-body matching later if the editor
 * feedback warrants it.
 *
 * ## Why we count alt-less images per document, not per image
 *
 * The dashboard surfaces total issue counts. Storing one row per
 * image would blow up the issue table for pages with 20+
 * unannotated photos and provide no extra value (the editor still
 * gets a single "12 images need alt text" row in the UI). We
 * collapse to one row per (document, type) with `count` carrying
 * the per-document magnitude.
 */
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
 * Return the longest plausible HTML payload from a document. CMS
 * collections use different field names (`body`, `content`,
 * `richText`, `html`) so we try each and pick the longest — that
 * resolves the common case where a document has both a plain
 * `excerpt` and a rich `body`, and we want to scan the body.
 */
function extractHtml(data) {
    const candidates = ['body', 'content', 'richText', 'richtext', 'html', 'description'];
    let best = '';
    for (const key of candidates) {
        const v = data[key];
        if (typeof v === 'string' && v.length > best.length)
            best = v;
    }
    return best;
}
const META_DESCRIPTION_KEYS = ['metaDescription', 'seoDescription', 'meta_description'];
function getMetaDescription(data) {
    for (const key of META_DESCRIPTION_KEYS) {
        const v = data[key];
        if (typeof v === 'string' && v.trim().length > 0)
            return v.trim();
    }
    // Nested SEO objects (e.g. data.seo.description).
    const seo = data['seo'];
    if (seo && typeof seo === 'object') {
        const obj = seo;
        const v = obj['description'] ?? obj['metaDescription'];
        if (typeof v === 'string' && v.trim().length > 0)
            return v.trim();
    }
    return null;
}
/**
 * Count <img> tags in the HTML that don't carry an `alt` attribute.
 * Matches the existing a11y rule in `a11y/index.ts` so the scanner
 * agrees with the per-document audit panel.
 */
export function countMissingAltImages(html) {
    if (!html)
        return 0;
    const re = /<img(?![^>]*\salt\s*=)[^>]*>/gi;
    let count = 0;
    // We don't iterate matches into an array — just count, so memory
    // stays bounded even on huge pages.
    while (re.exec(html) !== null)
        count++;
    return count;
}
/**
 * Extract candidate URLs from <a href="..."> attributes. Returns
 * the raw href values; classification (internal vs external,
 * absolute vs relative) happens in the caller.
 */
export function extractLinks(html) {
    if (!html)
        return [];
    const out = [];
    const re = /<a\b[^>]*\shref\s*=\s*("([^"]*)"|'([^']*)')/gi;
    let match;
    while ((match = re.exec(html)) !== null) {
        const url = (match[2] ?? match[3] ?? '').trim();
        if (url.length > 0)
            out.push(url);
    }
    return out;
}
/**
 * Decide whether a URL is "internal" relative to `siteOrigin`.
 * Internal links include:
 *   - relative paths: `/about`, `./page`, `../foo`
 *   - same-origin absolute URLs: `https://example.com/page`
 *
 * Excluded:
 *   - external URLs (different origin)
 *   - hash-only anchors (`#section`)
 *   - non-http schemes (`mailto:`, `tel:`, `javascript:`, etc.)
 *   - protocol-relative URLs to a different host (`//other.com/x`)
 */
export function classifyLink(url, siteOrigin) {
    const trimmed = url.trim();
    if (trimmed.length === 0)
        return { isInternal: false, resolved: null };
    if (trimmed.startsWith('#'))
        return { isInternal: false, resolved: null };
    // Reject non-http schemes outright.
    const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
    if (schemeMatch) {
        const scheme = schemeMatch[1].toLowerCase();
        if (scheme !== 'http' && scheme !== 'https')
            return { isInternal: false, resolved: null };
    }
    let parsedOrigin;
    try {
        parsedOrigin = new URL(siteOrigin);
    }
    catch {
        return { isInternal: false, resolved: null };
    }
    try {
        const resolved = new URL(trimmed, parsedOrigin);
        if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
            return { isInternal: false, resolved: null };
        }
        return {
            isInternal: resolved.origin === parsedOrigin.origin,
            resolved: resolved.toString(),
        };
    }
    catch {
        return { isInternal: false, resolved: null };
    }
}
/**
 * Single-link reachability probe. Default implementation used by
 * the cron — issues a HEAD with a 5s timeout, falls back to GET if
 * HEAD returns 405/501 (some frameworks reject HEAD).
 */
export async function checkInternalLinkReachable(url, timeoutMs = 5000) {
    const controller = new AbortController();
    const t = setTimeout(() => controller.abort(), timeoutMs);
    try {
        const head = await fetch(url, {
            method: 'HEAD',
            redirect: 'follow',
            signal: controller.signal,
        });
        if (head.status === 405 || head.status === 501) {
            // Method not allowed / not implemented → retry with GET.
            const get = await fetch(url, {
                method: 'GET',
                redirect: 'follow',
                signal: controller.signal,
            });
            return get.status < 400;
        }
        return head.status < 400;
    }
    catch {
        return false;
    }
    finally {
        clearTimeout(t);
    }
}
// ─── Scanner ───────────────────────────────────────────────────────────────
/**
 * Run all checks on a single document. The link checker is
 * concurrency-bounded to `LINK_CHECK_CONCURRENCY` per document so a
 * page with 200 links doesn't open 200 sockets at once.
 *
 * Each issue type appears at most once in the returned array. The
 * shape `{ type, count, details }` lets the caller upsert by
 * `(documentId, type)` and rely on `count` for the magnitude
 * surfaced in the dashboard.
 */
export async function scanDocument(doc, opts = {}) {
    const outdatedDays = opts.outdatedDays ?? 90;
    const issues = [];
    // 1. Missing meta description --------------------------------------------
    if (!getMetaDescription(doc.data)) {
        issues.push({ type: 'MISSING_META_DESCRIPTION', count: 1 });
    }
    // 2. Missing alt text ----------------------------------------------------
    const html = extractHtml(doc.data);
    const altLessImages = countMissingAltImages(html);
    if (altLessImages > 0) {
        issues.push({ type: 'MISSING_ALT_TEXT', count: altLessImages });
    }
    // 3. Outdated content ----------------------------------------------------
    const referenceTime = doc.publishedAt ?? doc.updatedAt;
    const ageMs = Date.now() - referenceTime.getTime();
    if (ageMs > outdatedDays * 24 * 60 * 60 * 1000) {
        issues.push({
            type: 'OUTDATED_CONTENT',
            count: 1,
            details: {
                ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
                threshold: outdatedDays,
            },
        });
    }
    // 4. Broken internal links ----------------------------------------------
    if (opts.checkLink && opts.siteOrigin) {
        const links = extractLinks(html);
        const internalUrls = new Set();
        for (const raw of links) {
            const { isInternal, resolved } = classifyLink(raw, opts.siteOrigin);
            if (isInternal && resolved)
                internalUrls.add(resolved);
        }
        const broken = [];
        if (internalUrls.size > 0) {
            const checker = opts.checkLink;
            await Promise.all(chunk([...internalUrls], LINK_CHECK_CONCURRENCY).map(async (group) => {
                const results = await Promise.all(group.map((u) => checker(u).catch(() => false)));
                group.forEach((u, i) => {
                    if (!results[i])
                        broken.push(u);
                });
            }));
        }
        if (broken.length > 0) {
            issues.push({
                type: 'BROKEN_INTERNAL_LINK',
                count: broken.length,
                details: { urls: broken.slice(0, 10) }, // cap stored details
            });
        }
    }
    return issues;
}
/**
 * Concurrency cap for the per-document link checker. Vercel
 * functions are wall-time bounded; opening 5 concurrent sockets
 * keeps us under the typical 60s soft cap even for pages with
 * 100+ internal links.
 */
const LINK_CHECK_CONCURRENCY = 5;
function chunk(arr, size) {
    if (size <= 0)
        return [arr];
    const out = [];
    for (let i = 0; i < arr.length; i += size)
        out.push(arr.slice(i, i + size));
    return out;
}
//# sourceMappingURL=scanner.js.map