import { getActuateConfig } from '../../config/runtime.js';
import { guardedDb } from '../guarded-db.js';
import { json, internalError, logSafeQueryFailure, safeCount, safeFindMany, isSecretMissing, requireAuth, } from '../route-helpers.js';
/**
 * Short-TTL cache for the dashboard's average SEO score.
 *
 * `/stats` otherwise reads the `data` JSON of up to 200 published
 * documents and scores each one on every load — the single heaviest
 * part of the endpoint. The score barely moves minute-to-minute, so we
 * cache it per content-scope (the JSON-stringified Prisma `where`, which
 * encodes the registered collection allowlist) for a short window.
 *
 * Module-level so it survives across requests within a warm isolate;
 * a cold start simply recomputes once. Keyed by scope so multi-config
 * isolates never read each other's score.
 */
let dashboardSeoCache = null;
const DASHBOARD_SEO_CACHE_TTL_MS = 60_000;
/** Test-only: reset the SEO score cache so cases don't bleed into each other. */
export function __resetDashboardSeoCache() {
    dashboardSeoCache = null;
}
/** Cheap meta-completeness score used by /stats and the SEO summary surface. */
export function computeLightSeoScore(data) {
    let score = 0;
    if (data.metaTitle || data.seoTitle)
        score += 25;
    if (data.metaDescription || data.seoDescription)
        score += 25;
    if (data.canonical)
        score += 25;
    if (data.schemaType)
        score += 25;
    return score;
}
export function registerDashboardRoutes(router) {
    const db = guardedDb;
    // ---------------------------------------------------------------------------
    // Stats route
    // ---------------------------------------------------------------------------
    const EMPTY_STATS = {
        totalDocuments: 0,
        totalMedia: 0,
        totalUsers: 0,
        formCount: 0,
        avgSeoScore: 0,
        webhookCount: 0,
        webhookActiveCount: 0,
        collectionCounts: {},
        statusCounts: {},
        recentDocuments: [],
    };
    const EMPTY_DELIVERY_STATS = {
        hasData: false,
        requests24h: 0,
        requestsPrev24h: 0,
        deltaPercent: null,
        errorRate: 0,
        errorCount: 0,
        avgLatencyMs: 0,
        p95LatencyMs: 0,
        maxLatencyMs: 0,
        webhookCount: 0,
        webhookActiveCount: 0,
    };
    const EMPTY_CONTENT_HEALTH = {
        score: 100,
        label: 'Good',
        counts: {
            MISSING_META_DESCRIPTION: 0,
            MISSING_ALT_TEXT: 0,
            BROKEN_INTERNAL_LINK: 0,
            OUTDATED_CONTENT: 0,
        },
        totalIssues: 0,
        lastScanAt: null,
    };
    async function safeGroupBy(model, by, where) {
        if (!model || typeof model !== 'object')
            return [];
        let groupByFn;
        try {
            groupByFn = model.groupBy;
        }
        catch {
            return [];
        }
        if (typeof groupByFn !== 'function')
            return [];
        try {
            return await groupByFn.call(model, { by, where: where ?? {}, _count: { _all: true } });
        }
        catch (err) {
            logSafeQueryFailure('safeGroupBy', err);
            return [];
        }
    }
    /**
     * Average SEO score across published content, cached per content-scope
     * for `DASHBOARD_SEO_CACHE_TTL_MS`. On a cache hit this returns without
     * touching the database — avoiding the 200-document `data` read + scan
     * that dominates `/stats`. Failures from `safeFindMany` surface as an
     * empty list (score 0) and are not cached, so a transient DB blip
     * doesn't pin a stale 0.
     */
    async function getCachedAvgSeoScore(model, where) {
        const key = JSON.stringify(where);
        const now = Date.now();
        if (dashboardSeoCache && dashboardSeoCache.key === key && dashboardSeoCache.expires > now) {
            return dashboardSeoCache.value;
        }
        const docs = await safeFindMany(model, {
            where,
            select: { data: true },
            take: 200,
        });
        let value = 0;
        if (docs.length > 0) {
            const total = docs.reduce((sum, doc) => sum + computeLightSeoScore(doc.data ?? {}), 0);
            value = Math.round(total / docs.length);
        }
        dashboardSeoCache = { key, value, expires: now + DASHBOARD_SEO_CACHE_TTL_MS };
        return value;
    }
    /**
     * Collection slugs that are written by the benchmark suite and a few
     * other internal/test fixtures. Documents in these collections are
     * never editor-facing content and should never appear in the
     * dashboard's stats, counts, or recent-activity feed.
     *
     * This list is the fallback when the consumer ships collection
     * definitions only to the admin client (e.g. `apps/dev`) and
     * `getActuateConfig()?.collections` is therefore empty on the
     * server. Production consumers normally pass `collections` to
     * `handleActuateAPI({ config })`, in which case the registered
     * slugs become the allowlist and this denylist is unused.
     */
    const INTERNAL_COLLECTION_SLUGS = ['benchmarks', 'bench'];
    /**
     * Build the Prisma `where` clause for "user-facing content".
     *
     *   - If the CMS config registered collections, restrict to those
     *     slugs — anything else is orphan/test data.
     *   - Otherwise fall back to excluding the internal denylist.
     *
     * Always excludes soft-deleted rows. Callers can spread additional
     * filters on top (`status: 'PUBLISHED'`, etc.).
     */
    function userContentWhere(extra = {}) {
        const cfg = getActuateConfig();
        const registered = Object.keys(cfg?.collections ?? {});
        const collectionFilter = registered.length > 0 ? { in: registered } : { notIn: [...INTERNAL_COLLECTION_SLUGS] };
        return { deletedAt: null, collection: collectionFilter, ...extra };
    }
    router.get('/stats', async (request) => {
        try {
            if (!isSecretMissing()) {
                const auth = await requireAuth(request);
                if (auth.error)
                    return auth.error;
            }
            let d;
            try {
                d = db();
            }
            catch {
                return json({ data: EMPTY_STATS });
            }
            // `userContentWhere()` filters out benchmark / orphan / internal
            // collections so the dashboard surfaces editor content only. See
            // the helper above for the allowlist-vs-denylist behaviour.
            const contentWhere = userContentWhere();
            const publishedContentWhere = userContentWhere({ status: 'PUBLISHED' });
            const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoScoreResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
                safeCount(d.document, contentWhere),
                safeCount(d.media),
                safeCount(d.user),
                safeFindMany(d.document, {
                    // Prisma 7's `'join'` strategy folds the User relation into a
                    // single LATERAL SQL query so the dashboard read costs one
                    // round-trip instead of two. On the production Vercel Function
                    // -> Prisma Postgres path that saves ~80 ms (one full network
                    // RTT) on every dashboard load.
                    relationLoadStrategy: 'join',
                    where: contentWhere,
                    orderBy: { updatedAt: 'desc' },
                    take: 20,
                    select: {
                        id: true,
                        title: true,
                        status: true,
                        collection: true,
                        updatedAt: true,
                        createdById: true,
                        createdBy: { select: { name: true, email: true } },
                    },
                }),
                safeGroupBy(d.document, ['collection'], contentWhere),
                safeGroupBy(d.document, ['status'], contentWhere),
                safeCount(d.formSubmission),
                // Cached per content-scope so the 200-doc SEO read+scan runs at
                // most once per TTL window across warm requests.
                getCachedAvgSeoScore(d.document, publishedContentWhere),
                // Cheap counts so the dashboard's "Content Delivery" tile doesn't need
                // a second round-trip. `webhookEndpoint` may not exist on every host,
                // so `safeCount` swallows missing-model errors and returns 0.
                safeCount(d.webhookEndpoint),
                safeCount(d.webhookEndpoint, { active: true }),
            ]);
            const collectionCounts = {};
            if (collGroupResult.status === 'fulfilled') {
                for (const g of collGroupResult.value)
                    collectionCounts[g.collection] = g._count._all;
            }
            const statusCounts = {};
            if (statusGroupResult.status === 'fulfilled') {
                for (const g of statusGroupResult.value)
                    statusCounts[g.status] = g._count._all;
            }
            const avgSeoScore = seoScoreResult.status === 'fulfilled' ? seoScoreResult.value : 0;
            const recentDocs = recentResult.status === 'fulfilled'
                ? recentResult.value.map((doc) => ({
                    id: doc.id,
                    title: doc.title,
                    status: doc.status,
                    collection: doc.collection,
                    updatedAt: doc.updatedAt,
                    author: doc.createdBy?.name ?? doc.createdBy?.email ?? doc.createdById,
                }))
                : [];
            return json({
                data: {
                    totalDocuments: docResult.status === 'fulfilled' ? docResult.value : 0,
                    totalMedia: mediaResult.status === 'fulfilled' ? mediaResult.value : 0,
                    totalUsers: userResult.status === 'fulfilled' ? userResult.value : 0,
                    formCount: formResult.status === 'fulfilled' ? formResult.value : 0,
                    avgSeoScore,
                    webhookCount: webhookTotalResult.status === 'fulfilled' ? webhookTotalResult.value : 0,
                    webhookActiveCount: webhookActiveResult.status === 'fulfilled' ? webhookActiveResult.value : 0,
                    collectionCounts,
                    statusCounts,
                    recentDocuments: recentDocs,
                },
            });
        }
        catch {
            return json({ data: EMPTY_STATS });
        }
    });
    // ---------------------------------------------------------------------------
    // Content Health — backs the dashboard "Content Health" issue rows
    // ---------------------------------------------------------------------------
    //
    // Aggregates rows from `actuate_content_issues` (populated nightly by
    // /cron/content-health) by `type`, returning the totals the dashboard
    // surfaces. We also compute an overall content-health score so the
    // big number + progress bar stay consistent with the per-row counts.
    //
    // Score model:
    //   - Start at 100.
    //   - Subtract 5 per missing-meta-description issue.
    //   - Subtract 2 per missing-alt image.
    //   - Subtract 10 per broken internal link.
    //   - Subtract 3 per outdated document.
    // Floor at 0. The weights reflect SEO impact (broken links and
    // missing meta hit hardest) and are kept here, not in the DB, so we
    // can tune them without a migration.
    router.get('/stats/content-health', async (request) => {
        try {
            if (!isSecretMissing()) {
                const auth = await requireAuth(request);
                if (auth.error)
                    return auth.error;
            }
            let d;
            try {
                d = db();
            }
            catch {
                return json({ data: EMPTY_CONTENT_HEALTH });
            }
            if (!d?.contentIssue) {
                return json({ data: EMPTY_CONTENT_HEALTH });
            }
            let rows = [];
            try {
                rows = await d.contentIssue.groupBy({
                    by: ['type'],
                    _sum: { count: true },
                });
            }
            catch {
                return json({ data: EMPTY_CONTENT_HEALTH });
            }
            const counts = {
                MISSING_META_DESCRIPTION: 0,
                MISSING_ALT_TEXT: 0,
                BROKEN_INTERNAL_LINK: 0,
                OUTDATED_CONTENT: 0,
            };
            for (const r of rows) {
                counts[r.type] = r._sum.count ?? 0;
            }
            // Read-out via locals so TS narrows away the Record<string,..>
            // indexed-access undefined union — we know the keys are present
            // because we initialised them above.
            const cMeta = counts['MISSING_META_DESCRIPTION'] ?? 0;
            const cAlt = counts['MISSING_ALT_TEXT'] ?? 0;
            const cLinks = counts['BROKEN_INTERNAL_LINK'] ?? 0;
            const cOutdated = counts['OUTDATED_CONTENT'] ?? 0;
            // Penalty model — see comment above.
            let score = 100;
            score -= cMeta * 5;
            score -= cAlt * 2;
            score -= cLinks * 10;
            score -= cOutdated * 3;
            score = Math.max(0, Math.min(100, score));
            // Last scan timestamp — useful for the dashboard to display
            // "Last scanned X minutes ago" so editors don't think the
            // numbers are live.
            let lastScanAt = null;
            try {
                const latest = await d.contentIssue.findFirst({
                    orderBy: { lastSeenAt: 'desc' },
                    select: { lastSeenAt: true },
                });
                lastScanAt = latest?.lastSeenAt ?? null;
            }
            catch {
                /* ignore — empty table or stale schema */
            }
            return json({
                data: {
                    score,
                    label: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : 'Poor',
                    counts,
                    totalIssues: cMeta + cAlt + cLinks + cOutdated,
                    lastScanAt: lastScanAt?.toISOString() ?? null,
                },
            });
        }
        catch {
            return json({ data: EMPTY_CONTENT_HEALTH });
        }
    });
    // ---------------------------------------------------------------------------
    // Delivery API metrics — backs the dashboard "Delivery API" tiles
    // ---------------------------------------------------------------------------
    //
    // Reads from `actuate_api_request_metrics` (per-minute pre-aggregated
    // counters populated by `recordApiRequest` in handler-factory.ts) and
    // folds the last 24h into the four scalars the dashboard surfaces:
    //
    //   - requests24h, requestsPrev24h (delta = "+12% vs yesterday")
    //   - errorRate (4xx + 5xx as fraction of total)
    //   - avgLatencyMs, p95LatencyMs (latter via histogram merge)
    //   - webhooks (total + active count)
    //
    // The endpoint returns a complete, never-undefined object even when
    // the metrics table is empty — the dashboard distinguishes
    // "no traffic yet" from "table missing" via the boolean `hasData`
    // field so it can render an empty state without an extra round-trip.
    router.get('/stats/api-delivery', async (request) => {
        try {
            if (!isSecretMissing()) {
                const auth = await requireAuth(request);
                if (auth.error)
                    return auth.error;
            }
            let d;
            try {
                d = db();
            }
            catch {
                return json({ data: EMPTY_DELIVERY_STATS });
            }
            if (!d?.apiRequestMetric) {
                return json({ data: EMPTY_DELIVERY_STATS });
            }
            const now = new Date();
            const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
            const sincePrev24h = new Date(now.getTime() - 48 * 60 * 60 * 1000);
            const [currentResult, prevResult, webhookTotalResult, webhookActiveResult] = await Promise.allSettled([
                safeFindMany(d.apiRequestMetric, {
                    where: { bucketStart: { gte: since24h } },
                    select: {
                        statusBucket: true,
                        count: true,
                        sumLatencyMs: true,
                        maxLatencyMs: true,
                        latencyHistogram: true,
                    },
                }),
                safeFindMany(d.apiRequestMetric, {
                    where: { bucketStart: { gte: sincePrev24h, lt: since24h } },
                    select: { count: true },
                }),
                safeCount(d.webhookEndpoint),
                safeCount(d.webhookEndpoint, { active: true }),
            ]);
            const currentRows = currentResult.status === 'fulfilled' ? currentResult.value : [];
            const prevRows = prevResult.status === 'fulfilled' ? prevResult.value : [];
            let total = 0;
            let errorTotal = 0; // 4xx + 5xx
            let sumLatency = 0;
            let maxLatency = 0;
            // Histogram merge for p95 — fixed 12-bucket array (see
            // diagnostics/api-metrics.ts `HISTOGRAM_LENGTH`).
            const mergedHistogram = new Array(12).fill(0);
            for (const row of currentRows) {
                total += row.count;
                if (row.statusBucket === '4xx' || row.statusBucket === '5xx')
                    errorTotal += row.count;
                sumLatency += row.sumLatencyMs;
                if (row.maxLatencyMs > maxLatency)
                    maxLatency = row.maxLatencyMs;
                const h = row.latencyHistogram;
                if (Array.isArray(h)) {
                    for (let i = 0; i < mergedHistogram.length; i++) {
                        const v = h[i];
                        const current = mergedHistogram[i] ?? 0;
                        if (typeof v === 'number')
                            mergedHistogram[i] = current + v;
                    }
                }
            }
            const prevTotal = prevRows.reduce((s, r) => s + r.count, 0);
            const delta = prevTotal === 0 ? null : Math.round(((total - prevTotal) / prevTotal) * 1000) / 10; // 1-decimal %
            const errorRate = total === 0 ? 0 : errorTotal / total;
            const avgLatency = total === 0 ? 0 : sumLatency / total;
            // p95: walk the merged histogram cumulatively, return the upper
            // bound of the bucket that crosses the 0.95 watermark.
            const LATENCY_BUCKET_UPPER = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
            let p95Latency = 0;
            if (total > 0) {
                const target = total * 0.95;
                let acc = 0;
                for (let i = 0; i < mergedHistogram.length; i++) {
                    acc += mergedHistogram[i] ?? 0;
                    if (acc >= target) {
                        p95Latency =
                            i < LATENCY_BUCKET_UPPER.length
                                ? LATENCY_BUCKET_UPPER[i]
                                : LATENCY_BUCKET_UPPER[LATENCY_BUCKET_UPPER.length - 1] * 2;
                        break;
                    }
                }
            }
            const webhookTotal = webhookTotalResult.status === 'fulfilled' ? webhookTotalResult.value : 0;
            const webhookActive = webhookActiveResult.status === 'fulfilled' ? webhookActiveResult.value : 0;
            return json({
                data: {
                    hasData: total > 0,
                    requests24h: total,
                    requestsPrev24h: prevTotal,
                    deltaPercent: delta,
                    errorRate: Math.round(errorRate * 10000) / 10000, // 4-dec precision
                    errorCount: errorTotal,
                    avgLatencyMs: Math.round(avgLatency * 10) / 10,
                    p95LatencyMs: p95Latency,
                    maxLatencyMs: Math.round(maxLatency * 10) / 10,
                    webhookCount: webhookTotal,
                    webhookActiveCount: webhookActive,
                },
            });
        }
        catch {
            return json({ data: EMPTY_DELIVERY_STATS });
        }
    });
    // ---------------------------------------------------------------------------
    // Global search
    // ---------------------------------------------------------------------------
    router.get('/search/global', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const url = new URL(request.url, 'http://localhost');
            const q = (url.searchParams.get('q') ?? '').trim();
            if (!q)
                return json({ data: { documents: [], media: [], users: [] } });
            const d = db();
            // Documents and media are visible to all signed-in users; the user
            // directory is gated to EDITOR+ so a CLIENT can't enumerate the team
            // (or harvest emails for spear-phishing).
            const canSeeUserDirectory = auth.session.role === 'ADMIN' || auth.session.role === 'EDITOR';
            const [documents, media, users] = await Promise.all([
                safeFindMany(d.document, {
                    where: {
                        deletedAt: null,
                        OR: [
                            { title: { contains: q, mode: 'insensitive' } },
                            { plainText: { contains: q, mode: 'insensitive' } },
                        ],
                    },
                    take: 10,
                    orderBy: { updatedAt: 'desc' },
                    select: {
                        id: true,
                        title: true,
                        slug: true,
                        collection: true,
                        status: true,
                        updatedAt: true,
                    },
                }),
                safeFindMany(d.media, {
                    where: {
                        OR: [
                            { filename: { contains: q, mode: 'insensitive' } },
                            { altText: { contains: q, mode: 'insensitive' } },
                        ],
                    },
                    take: 5,
                    orderBy: { createdAt: 'desc' },
                    select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
                }),
                canSeeUserDirectory
                    ? safeFindMany(d.user, {
                        where: {
                            isActive: true,
                            OR: [
                                { name: { contains: q, mode: 'insensitive' } },
                                { email: { contains: q, mode: 'insensitive' } },
                            ],
                        },
                        take: 5,
                        select: { id: true, name: true, email: true, role: true },
                    })
                    : Promise.resolve([]),
            ]);
            return json({ data: { documents, media, users } });
        }
        catch (err) {
            return internalError(err);
        }
    });
}
//# sourceMappingURL=dashboard.js.map