import { buildRelationLookup } from '../../actions.js';
import { populateRelations, parsePopulateParam } from '../../fields/relations.js';
import { ACTUATE_TAG_ROOT, actuateTag, cachedRead } from '../../cache/http.js';
import { createPreviewAdapter } from '../../preview/index.js';
import { coercePostHeader, layoutTreeToSections, collectSectionImageUrls, applyMediaMeta, featuredImageUrl, defaultPostHeader, } from '../../sections/index.js';
import { getActuateConfig } from '../../config/runtime.js';
import { matchPathToCollection } from '../../seo/resolve-path.js';
import { guardedDb } from '../guarded-db.js';
import { loadEffectiveConfig, siteUrlFromRequest } from './seo-public.js';
import { createLogger } from '../../diagnostics/logger.js';
import { json, jsonWithCache, errorResponse, internalError, getSessionSecret, } from '../route-helpers.js';
const resolveLogger = createLogger('resolve');
export function registerUrlResolutionRoutes(router) {
    const db = guardedDb;
    // ---------------------------------------------------------------------------
    // URL Resolution — maps a public URL path to its document
    // ---------------------------------------------------------------------------
    const MAX_RESOLVE_DEPTH = 10;
    async function resolveLayout(path, docData, matchedCollection) {
        const config = getActuateConfig();
        const layoutConfig = config?.layout;
        if (!layoutConfig?.regions)
            return {};
        const regionNames = Object.keys(layoutConfig.regions);
        if (regionNames.length === 0)
            return {};
        const allowedCollections = new Set();
        for (const region of regionNames) {
            const col = layoutConfig.regions[region]?.collection;
            if (col)
                allowedCollections.add(col);
        }
        const resolved = {};
        const unresolved = new Set(regionNames);
        const docLayout = (docData?._layout ?? {});
        for (const region of regionNames) {
            if (docLayout[region] && typeof docLayout[region] === 'string') {
                resolved[region] = docLayout[region];
                unresolved.delete(region);
            }
        }
        if (unresolved.size > 0 && layoutConfig.inherit !== false) {
            const segments = path
                .replace(/^\/|\/$/g, '')
                .split('/')
                .filter(Boolean);
            const walkDepth = Math.min(segments.length - 1, MAX_RESOLVE_DEPTH);
            for (let i = walkDepth; i > 0 && unresolved.size > 0; i--) {
                const parentSlug = segments.slice(0, i).join('/');
                const parentDoc = await db().document.findFirst({
                    where: {
                        collection: matchedCollection,
                        deletedAt: null,
                        status: 'PUBLISHED',
                        OR: [{ data: { path: ['slug'], equals: parentSlug } }, { slug: parentSlug }],
                    },
                    select: { data: true },
                });
                if (!parentDoc)
                    continue;
                const parentLayout = (parentDoc.data?._layout ?? {});
                for (const region of [...unresolved]) {
                    if (parentLayout[region] && typeof parentLayout[region] === 'string') {
                        resolved[region] = parentLayout[region];
                        unresolved.delete(region);
                    }
                }
            }
        }
        const settingsGlobal = await db().document.findFirst({
            where: { collection: 'settings', deletedAt: null },
            select: { data: true },
        });
        const settingsDefaults = (settingsGlobal?.data?.defaultLayout ?? {});
        for (const region of [...unresolved]) {
            const fallback = layoutConfig.defaults?.[region] ?? settingsDefaults[region];
            if (fallback && typeof fallback === 'string') {
                resolved[region] = fallback;
                unresolved.delete(region);
            }
        }
        if (Object.keys(resolved).length === 0)
            return {};
        const variantIds = [...new Set(Object.values(resolved))];
        const allowedCollectionList = [...allowedCollections];
        const variants = await db().document.findMany({
            where: {
                id: { in: variantIds },
                collection: { in: allowedCollectionList },
                status: 'PUBLISHED',
                deletedAt: null,
            },
        });
        const variantMap = new Map(variants.map((v) => [v.id, v]));
        const result = {};
        for (const [region, docId] of Object.entries(resolved)) {
            const expectedCollection = layoutConfig.regions[region]?.collection;
            const variant = variantMap.get(docId);
            if (variant && variant.collection === expectedCollection) {
                result[region] = { id: variant.id, collection: variant.collection, data: variant.data };
            }
        }
        return result;
    }
    router.get('/resolve', async (request) => {
        try {
            const url = new URL(request.url);
            const pathParam = url.searchParams.get('path');
            if (!pathParam) {
                return errorResponse('Missing required "path" query parameter', 400);
            }
            const match = matchPathToCollection(getActuateConfig(), pathParam);
            if (!match) {
                return errorResponse('Could not resolve path', 404);
            }
            const matchedCollection = match.collection;
            const docSlug = match.slug;
            // ?preview=<token> — when a valid preview token is presented we drop
            // the `status: PUBLISHED` filter so admins (and clients with a signed
            // share link) can render an unpublished draft at the document's real
            // URL. The token is tied to a (collection, documentId) pair so we
            // verify the URL actually points at the document the token was
            // minted for; this prevents using one token to peek at unrelated
            // drafts in the same collection.
            //
            // Validation happens BEFORE the runtime-cache lookup so a preview
            // session can never read from (or write to) the shared cache. The
            // small price is that every `/resolve?preview=` call runs a JWT
            // verification even on what would otherwise be a cache hit —
            // worth it; the alternative is a cache poisoned with draft data.
            const previewToken = url.searchParams.get('preview');
            let previewSession = null;
            if (previewToken) {
                try {
                    const preview = createPreviewAdapter(getSessionSecret(), db());
                    const session = await preview.validatePreviewToken(previewToken);
                    if (session) {
                        previewSession = {
                            collection: session.collection,
                            documentId: session.documentId,
                            expiresAt: session.expiresAt,
                        };
                    }
                }
                catch (err) {
                    resolveLogger.error('preview token validation failed', {
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
            }
            const isRootPath = match.isRoot;
            // Heavy compute (doc lookup → populate → meta composition) lives in
            // a closure so the optional runtime-cache layer can short-circuit
            // it on a hit. The cache layer is bypassed entirely when a preview
            // session is active — a draft must never enter the shared cache.
            const buildResolveBody = async () => {
                const slugFilter = {
                    OR: isRootPath
                        ? [
                            { data: { path: ['slug'], equals: 'home' } },
                            { data: { path: ['slug'], equals: 'index' } },
                            { slug: 'home' },
                            { slug: 'index' },
                        ]
                        : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
                };
                const doc = previewSession
                    ? await db().document.findFirst({
                        where: {
                            collection: matchedCollection,
                            deletedAt: null,
                            ...slugFilter,
                        },
                    })
                    : await db().document.findFirst({
                        where: {
                            collection: matchedCollection,
                            deletedAt: null,
                            status: 'PUBLISHED',
                            ...slugFilter,
                        },
                    });
                if (!doc) {
                    return errorResponse('Document not found', 404);
                }
                // Enforce the token ↔ document binding. A leaked or copied token can
                // only unlock the exact document it was issued for.
                if (previewSession &&
                    (previewSession.documentId !== doc.id || previewSession.collection !== matchedCollection)) {
                    return errorResponse('Preview token does not match this URL', 403);
                }
                let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
                // Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
                // into `{ id, title, slug, status, collection }` summaries so the
                // frontend renders authors / related case studies without a second
                // request per reference. `resolve` is the highest-leverage place to
                // do this because it's what marketing pages call from SSR.
                const populateOpts = parsePopulateParam(new URL(request.url).searchParams.get('populate'));
                if (populateOpts) {
                    const fields = getActuateConfig()?.collections?.[matchedCollection]?.fields;
                    if (fields) {
                        try {
                            docData = await populateRelations(fields, docData, buildRelationLookup(db()), populateOpts);
                        }
                        catch (err) {
                            resolveLogger.error('populate failed', {
                                error: err instanceof Error ? err.message : String(err),
                            });
                        }
                    }
                }
                const layout = await resolveLayout(pathParam, docData, matchedCollection);
                const { _layout: layoutTree, ...cleanData } = docData;
                // LEGACY fallback (ADR 0002): builder pages now compile `_layout` to
                // canonical `data.sections` on save, so this branch is a no-op for any
                // document written after that change. It still fires for LEGACY pages
                // authored in the tree before compile-on-save (and not yet migrated via
                // `migratePageBuilderSections`): when such a doc has no flat `sections`,
                // derive a best-effort PageSection[] at read time so page-builder content
                // is never stranded. Non-destructive — only the response is enriched.
                if ((!Array.isArray(cleanData.sections) || cleanData.sections.length === 0) &&
                    layoutTree &&
                    typeof layoutTree === 'object') {
                    const bridged = layoutTreeToSections(layoutTree);
                    if (bridged.length > 0)
                        cleanData.sections = bridged;
                }
                // WS-E E3: enrich section images + the post featured image with
                // intrinsic width/height + library alt text from `actuate_media`,
                // matched by the stored URL. Additive and best-effort — a media-lookup
                // failure must never break resolve. Dimensions let consumers render the
                // LCP/CLS-safe `next/image` path instead of an unsized `<img>`.
                const featuredUrl = featuredImageUrl(cleanData.featuredImage);
                let featuredImageMeta;
                {
                    try {
                        const sectionUrls = Array.isArray(cleanData.sections)
                            ? collectSectionImageUrls(cleanData.sections)
                            : [];
                        const urls = [...new Set([...sectionUrls, ...(featuredUrl ? [featuredUrl] : [])])];
                        if (urls.length > 0) {
                            const records = await db().media.findMany({
                                where: {
                                    deletedAt: null,
                                    OR: [{ storageKey: { in: urls } }, { publicUrl: { in: urls } }],
                                },
                                select: {
                                    storageKey: true,
                                    publicUrl: true,
                                    width: true,
                                    height: true,
                                    altText: true,
                                    title: true,
                                },
                            });
                            const lookup = new Map();
                            for (const r of records) {
                                const meta = {
                                    width: r.width,
                                    height: r.height,
                                    alt: r.altText,
                                    title: r.title,
                                };
                                if (r.storageKey)
                                    lookup.set(r.storageKey, meta);
                                if (r.publicUrl)
                                    lookup.set(r.publicUrl, meta);
                            }
                            if (lookup.size > 0 && Array.isArray(cleanData.sections)) {
                                cleanData.sections = applyMediaMeta(cleanData.sections, lookup);
                            }
                            if (featuredUrl)
                                featuredImageMeta = lookup.get(featuredUrl);
                        }
                    }
                    catch (err) {
                        resolveLogger.error('media enrichment failed', {
                            error: err instanceof Error ? err.message : String(err),
                        });
                    }
                }
                // Compose page meta + JSON-LD up front so client renderers (Next.js
                // generateMetadata, plain SSR, MCP agents) don't have to re-derive
                // schema, OG tags, and canonical URLs from raw doc data. Reads the
                // collection's SEO config and the site-wide SEO defaults — with
                // DB overrides from /seo/config applied on top of the static config.
                const cfg = await loadEffectiveConfig();
                const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
                // Post-type documents render a header composed from the post type's
                // template (`post-templates` doc, matched by slug == the post-type
                // slug). The template is the single source of truth for header design
                // — editing it restyles every post of that type — so we resolve it
                // here at read time rather than denormalizing onto each post. Read it
                // directly (no ActionContext): the header is non-sensitive design
                // metadata and resolve is a public endpoint. Falls back to the default
                // header when the type has no saved template yet.
                let templateHeader = null;
                // Author profile (from the document's creator) + related posts (recent
                // published siblings in the same collection) so the Author Bio and
                // Related Posts sections render real data instead of placeholders.
                // Posts have no author/relatedPosts relationship fields, so these are
                // derived here rather than via ?populate.
                let postAuthor = null;
                let relatedPosts = [];
                if (collectionDef?.type === 'post') {
                    try {
                        const tplDoc = await db().document.findFirst({
                            where: {
                                collection: 'post-templates',
                                deletedAt: null,
                                OR: [
                                    { slug: matchedCollection },
                                    { data: { path: ['slug'], equals: matchedCollection } },
                                ],
                            },
                        });
                        const tplData = tplDoc?.data && typeof tplDoc.data === 'object'
                            ? tplDoc.data
                            : undefined;
                        templateHeader = coercePostHeader(tplData?.header);
                    }
                    catch (err) {
                        resolveLogger.error('post-template lookup failed', {
                            error: err instanceof Error ? err.message : String(err),
                        });
                        templateHeader = defaultPostHeader();
                    }
                    try {
                        if (doc.createdById) {
                            const author = await db().user.findUnique({
                                where: { id: doc.createdById },
                                select: { name: true, avatarUrl: true },
                            });
                            if (author) {
                                postAuthor = { name: author.name, avatarUrl: author.avatarUrl ?? undefined };
                            }
                        }
                    }
                    catch (err) {
                        resolveLogger.error('post author lookup failed', {
                            error: err instanceof Error ? err.message : String(err),
                        });
                    }
                    try {
                        const prefix = (collectionDef.urlPrefix ?? matchedCollection ?? '').replace(/^\/|\/$/g, '');
                        const siblings = await db().document.findMany({
                            where: {
                                collection: matchedCollection,
                                status: 'PUBLISHED',
                                deletedAt: null,
                                id: { not: doc.id },
                            },
                            orderBy: { publishedAt: 'desc' },
                            take: 3,
                        });
                        relatedPosts = siblings.map((s) => {
                            const sd = s.data && typeof s.data === 'object' ? s.data : {};
                            const slug = s.slug ?? (typeof sd.slug === 'string' ? sd.slug : '') ?? '';
                            const title = (typeof sd.title === 'string' && sd.title) || s.title || 'Untitled post';
                            const excerpt = typeof sd.excerpt === 'string' ? sd.excerpt : undefined;
                            return { title, excerpt, url: slug ? `/${prefix}/${slug}` : undefined };
                        });
                    }
                    catch (err) {
                        resolveLogger.error('related posts lookup failed', {
                            error: err instanceof Error ? err.message : String(err),
                        });
                    }
                }
                const { composePageMeta, resolveDocumentUrl } = await import('../../seo/page-meta.js');
                // hreflang alternates: when i18n runs with >1 locale and this document
                // belongs to a translation group, emit `<link rel="alternate" hreflang>`
                // for each sibling so search engines serve the right language variant.
                // Mirrors the sitemap's hreflang logic. Best-effort — a lookup failure
                // must never break the resolve response.
                let hreflangAlternates;
                const i18nLocales = cfg?.i18n?.locales ?? [];
                if (i18nLocales.length > 1 && doc.localeGroupId) {
                    try {
                        const base = siteUrlFromRequest(request, cfg);
                        const defaultLocale = cfg?.i18n?.defaultLocale;
                        const siblings = await db().document.findMany({
                            where: {
                                deletedAt: null,
                                status: 'PUBLISHED',
                                localeGroupId: doc.localeGroupId,
                            },
                            select: { slug: true, data: true, locale: true, collection: true },
                        });
                        const list = siblings.map((s) => {
                            const sData = s.data || {};
                            const urlPrefix = cfg?.collections?.[s.collection]?.urlPrefix;
                            return {
                                hreflang: s.locale ?? defaultLocale ?? 'und',
                                href: resolveDocumentUrl({ siteUrl: base, urlPrefix, slug: s.slug, data: sData }),
                            };
                        });
                        if (list.length >= 2) {
                            const fallback = list.find((a) => a.hreflang === defaultLocale) ?? list[0];
                            if (fallback)
                                list.push({ hreflang: 'x-default', href: fallback.href });
                            hreflangAlternates = list;
                        }
                    }
                    catch (err) {
                        resolveLogger.error('hreflang lookup failed', {
                            error: err instanceof Error ? err.message : String(err),
                        });
                    }
                }
                const composed = composePageMeta({
                    doc: {
                        id: doc.id,
                        collection: doc.collection,
                        slug: doc.slug ?? cleanData.slug ?? null,
                        data: cleanData,
                        publishedAt: doc.publishedAt,
                        updatedAt: doc.updatedAt,
                        structuredData: doc.structuredData ?? null,
                        pageSettings: cleanData.pageSettings ?? null,
                    },
                    collection: collectionDef,
                    config: cfg ?? null,
                    siteUrl: siteUrlFromRequest(request, cfg),
                    alternates: hreflangAlternates,
                });
                return {
                    data: {
                        id: doc.id,
                        collection: doc.collection,
                        data: cleanData,
                        status: doc.status,
                        publishedAt: doc.publishedAt,
                        structuredData: composed.jsonLd ?? doc.structuredData,
                    },
                    meta: {
                        title: composed.title,
                        description: composed.description,
                        canonical: composed.canonical,
                        url: composed.url,
                        tags: composed.meta,
                        html: composed.metaHtml,
                    },
                    // Next.js `generateMetadata()`-compatible object so the public site
                    // can spread it directly without re-deriving OG/Twitter/robots tags.
                    metadata: composed.metadata,
                    jsonLd: composed.jsonLd,
                    jsonLdHtml: composed.jsonLdHtml,
                    ...(templateHeader ? { template: { header: templateHeader } } : {}),
                    ...(collectionDef?.type === 'post'
                        ? {
                            post: {
                                author: postAuthor,
                                relatedPosts,
                                // Featured image with intrinsic dimensions (from the media
                                // library) so the post header renders a dimensioned,
                                // CLS-free LCP image rather than an unsized fallback.
                                ...(featuredUrl
                                    ? {
                                        featuredImage: {
                                            url: featuredUrl,
                                            width: featuredImageMeta?.width ?? null,
                                            height: featuredImageMeta?.height ?? null,
                                            alt: featuredImageMeta?.alt ?? null,
                                        },
                                    }
                                    : {}),
                            },
                        }
                        : {}),
                    ...(Object.keys(layout).length > 0 ? { layout } : {}),
                    ...(previewSession
                        ? {
                            preview: {
                                active: true,
                                expiresAt: previewSession.expiresAt.toISOString(),
                            },
                        }
                        : {}),
                };
            };
            // Preview-token requests bypass the cache (CDN + Runtime) so a
            // leaked cached preview can never show up for anonymous traffic.
            // Anonymous reads flow through cachedRead — coarse tags (the
            // wholesale `actuate` tag + a path tag) so every write drains
            // every cached resolve entry. Per-doc tagging would require
            // resolving the doc id BEFORE the cache lookup, which defeats
            // the purpose of caching at all.
            if (previewSession) {
                const body = await buildResolveBody();
                return body instanceof Response ? body : json(body);
            }
            return cachedRead(request, {
                cache: getActuateConfig()?.cache,
                tags: [ACTUATE_TAG_ROOT, actuateTag('path', pathParam)],
                wrap: jsonWithCache,
                build: buildResolveBody,
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
}
//# sourceMappingURL=url-resolution.js.map