import { buildSchemaGraph } from '../content/structured-data.js';
import { buildNextVerification, buildVerificationMetaEntries, generateMetaTags, generateNextMetadata, renderMetaTagsHtml, renderVerificationMetaHtml, } from './meta-tags.js';
import { resolveRobotsDirectives, resolveSeoEnvironment } from './robots.js';
import { applySiteTitleTemplate } from './title-templates.js';
/**
 * Maps a collection slug to the most appropriate Schema.org type when the
 * config doesn't specify one. Conservative fallback to WebPage.
 *
 * Mirrors `@actuate-media/plugin-seo` `detectSchemaType()` but is duplicated
 * here so cms-core has no runtime dependency on the SEO plugin.
 */
const SLUG_TO_SCHEMA = {
    posts: 'BlogPosting',
    post: 'BlogPosting',
    blog: 'BlogPosting',
    articles: 'Article',
    article: 'Article',
    news: 'NewsArticle',
    pages: 'WebPage',
    page: 'WebPage',
    team: 'Person',
    staff: 'Person',
    author: 'Person',
    authors: 'Person',
    events: 'Event',
    event: 'Event',
    faq: 'FAQPage',
    faqs: 'FAQPage',
    services: 'Service',
    service: 'Service',
    jobs: 'JobPosting',
    careers: 'JobPosting',
    locations: 'LocalBusiness',
    location: 'LocalBusiness',
    testimonials: 'Review',
    testimonial: 'Review',
    products: 'Product',
    product: 'Product',
    'case-studies': 'Article',
    'case-study': 'Article',
    portfolios: 'CreativeWork',
    portfolio: 'CreativeWork',
};
function detectSchemaType(collectionSlug) {
    const normalized = collectionSlug.toLowerCase();
    if (SLUG_TO_SCHEMA[normalized])
        return SLUG_TO_SCHEMA[normalized];
    // Soft match — `blog-posts` → BlogPosting, etc.
    for (const [key, schemaType] of Object.entries(SLUG_TO_SCHEMA)) {
        if (normalized.includes(key))
            return schemaType;
    }
    return 'WebPage';
}
function trimTrailingSlash(s) {
    return s.replace(/\/+$/, '');
}
/**
 * Resolve the canonical public URL for a document. This is the SINGLE source of
 * truth shared by the public meta composer (`composePageMeta`, which renders the
 * `<link rel="canonical">` / `og:url`) and the XML sitemap, so the two can never
 * disagree and send conflicting signals to crawlers.
 *
 * Precedence mirrors the rest of the platform (`pages-service`,
 * `page-editor-service`, `/resolve`):
 *   1. An explicit `data.path` (the full URL path an editor set, e.g. `/` for
 *      the homepage or `/about/story`) always wins.
 *   2. A `home`/`index`/empty slug maps to the site root.
 *   3. Otherwise `<urlPrefix>/<slug>`.
 */
export function resolveDocumentUrl(params) {
    const base = trimTrailingSlash(params.siteUrl);
    const data = params.data ?? {};
    const explicitPath = typeof data.path === 'string' ? data.path.trim() : '';
    if (explicitPath) {
        const normalized = `/${explicitPath.replace(/^\/+|\/+$/g, '')}`;
        return normalized === '/' ? base || '/' : `${base}${normalized}`;
    }
    const slug = (params.slug ?? data.slug ?? '') || '';
    if (slug === 'home' || slug === 'index' || slug === '')
        return base || '/';
    const prefix = (params.urlPrefix ?? '').replace(/^\/|\/$/g, '');
    return prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
}
/**
 * Resolve the canonical URL a document will emit — explicit override or auto
 * from {@link resolveDocumentUrl}. Matches the fallback used in
 * `composePageMeta()` so audits and render stay aligned.
 */
export function resolveEffectiveCanonicalUrl(params) {
    const data = params.data ?? {};
    const page = pageSettingsFromData(data);
    const explicit = pickString(page.canonical, data.canonical, page.canonicalUrl, data.canonicalUrl);
    if (explicit)
        return explicit;
    return resolveDocumentUrl({
        siteUrl: params.siteUrl ?? '',
        urlPrefix: params.urlPrefix,
        slug: params.slug,
        data,
    });
}
function pageSettingsFromData(data) {
    const ps = data._pageSettings ?? data.pageSettings;
    return ps && typeof ps === 'object' && !Array.isArray(ps) ? ps : {};
}
/**
 * Resolve meta title, description, OG image, and noindex the same way
 * `composePageMeta()` does — so audits match what search engines receive.
 */
export function resolveEffectiveMeta(params) {
    const data = params.data ?? {};
    const page = params.pageSettings ?? pageSettingsFromData(data);
    const site = params.site;
    const siteUrl = trimTrailingSlash(params.siteUrl ?? site?.siteUrl ?? '');
    const siteName = site?.siteName ?? 'Site';
    const collection = params.collection;
    const collectionSlug = params.collectionSlug ?? collection?.slug ?? '';
    const collectionSeo = collection?.seo;
    const schemaType = pickString(page.schemaType, data.schemaType, data.structuredDataType, page.structuredDataType, collectionSeo?.defaultSchemaType) ?? detectSchemaType(collectionSlug);
    const explicitTitle = pickString(page.metaTitle, data.metaTitle, data.seoTitle);
    const rawTitle = pickString(data.title, page.title, params.docTitle) ?? 'Untitled';
    const hasTitleTemplate = typeof site?.titleTemplate === 'string' && site.titleTemplate.trim() !== '';
    const metaTitle = (explicitTitle
        ? explicitTitle
        : hasTitleTemplate
            ? applySiteTitleTemplate(site.titleTemplate, {
                page: rawTitle,
                site: siteName,
                type: pickString(collection?.labels?.singular, schemaType),
                author: pickString(data.authorName, data.author),
                date: asIsoDate(params.publishedAt ?? data.publishedAt)?.slice(0, 10),
                category: pickString(data.category, data.categories?.[0]),
                tag: pickString(data.tags?.[0], data.tag),
            })
            : rawTitle) || null;
    const metaDescription = pickString(page.metaDescription, data.metaDescription, data.seoDescription, page.keyTakeaway, data.keyTakeaway, data.excerpt, data.description, site?.defaultMetaDescription) ?? null;
    const realImageUrl = pickString(page.ogImage, data.ogImage, asImageUrl(data.featuredImage), asImageUrl(data.heroImage), asImageUrl(data.coverImage), asImageUrl(data.image), site?.defaultOgImage);
    const ogImage = realImageUrl ??
        (siteUrl && metaTitle
            ? `${siteUrl}/api/cms/og.png?title=${encodeURIComponent(metaTitle)}`
            : null);
    const robots = resolveRobotsDirectives({
        pageData: {
            robotsPolicy: page.robotsPolicy ?? data.robotsPolicy,
            noIndex: firstDefinedBool(page.noIndex, data.noIndex, data.noindex, data.robotsNoindex) ??
                collectionSeo?.defaultRobots?.noIndex,
            noFollow: firstDefinedBool(page.noFollow, data.noFollow, data.nofollow, data.robotsNofollow) ??
                collectionSeo?.defaultRobots?.noFollow,
        },
        settings: {
            defaultNoIndex: site?.robots?.defaultNoIndex,
            defaultNoFollow: site?.robots?.defaultNoFollow,
            noIndexNonProduction: site?.robots?.noIndexNonProduction,
        },
        environment: resolveSeoEnvironment(params.environment),
    });
    return { metaTitle, metaDescription, ogImage, noindex: robots.noIndex };
}
function pickString(...candidates) {
    for (const c of candidates) {
        if (typeof c === 'string' && c.trim().length > 0)
            return c;
    }
    return undefined;
}
/**
 * Return the first candidate that is a real boolean (coercing the common
 * string forms `"true"`/`"false"` that some field editors persist). Used to
 * unify the several robots field-name spellings without letting an explicit
 * `false` be skipped the way `??` over mixed types would.
 */
function firstDefinedBool(...candidates) {
    for (const c of candidates) {
        if (typeof c === 'boolean')
            return c;
        if (c === 'true')
            return true;
        if (c === 'false')
            return false;
    }
    return undefined;
}
/**
 * Coerce a media-ish value to a URL string. Accepts a bare URL string or a
 * media object (`{ url }` / `{ src }`); returns undefined otherwise.
 */
function asImageUrl(value) {
    if (typeof value === 'string' && value.trim().length > 0)
        return value;
    if (value && typeof value === 'object') {
        const obj = value;
        const url = obj.url ?? obj.src;
        if (typeof url === 'string' && url.trim().length > 0)
            return url;
    }
    return undefined;
}
function asIsoDate(v) {
    if (!v)
        return undefined;
    if (v instanceof Date)
        return v.toISOString();
    if (typeof v === 'string') {
        const d = new Date(v);
        return isNaN(d.getTime()) ? undefined : d.toISOString();
    }
    return undefined;
}
/**
 * Resolve the canonical freshness dates for a document so every public surface
 * (JSON-LD, Open Graph, etc.) reports the same recency. `datePublished` prefers
 * the DB `publishedAt` (set on first publish) but falls back to editorial date
 * fields authors set in the body (`publishedAt`/`publishedDate`/`publishDate`/
 * `date`). `dateModified` prefers `updatedAt` and never predates publication.
 */
export function resolveContentDates(params) {
    const data = params.data ?? {};
    const page = params.page ?? {};
    const datePublished = asIsoDate(params.doc.publishedAt ??
        data.publishedAt ??
        page.publishedAt ??
        data.publishedDate ??
        data.publishDate ??
        data.date);
    const dateModified = asIsoDate(params.doc.updatedAt ?? data.updatedAt ?? page.updatedAt) ?? datePublished;
    return {
        ...(datePublished ? { datePublished } : {}),
        ...(dateModified ? { dateModified } : {}),
    };
}
/** Schema.org types that the rich graph foundation already models as nodes. */
const FOUNDATION_TYPES = new Set(['WebPage', 'Article', 'BlogPosting', 'NewsArticle', 'FAQPage']);
/** Map a Schema.org type to the page-input content type the graph builder uses. */
function schemaTypeToPageType(type) {
    switch (type) {
        case 'BlogPosting':
            return 'blog';
        case 'Article':
        case 'NewsArticle':
            return 'article';
        case 'FAQPage':
            return 'faq';
        default:
            return 'page';
    }
}
/** Title-case a URL slug segment for a readable breadcrumb name. */
function humanizeSegment(segment) {
    const decoded = decodeURIComponent(segment).replace(/[-_]+/g, ' ').trim();
    return decoded.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
 * Derive a BreadcrumbList trail from the page URL: Home → each path segment →
 * the current page (named with its real title). Returns an empty array for the
 * homepage so we never emit a single-item or self-referential breadcrumb.
 */
function deriveBreadcrumbs(siteUrl, url, title) {
    const base = trimTrailingSlash(siteUrl);
    if (!url || url === base || url === `${base}/`)
        return [];
    const path = base && url.startsWith(base) ? url.slice(base.length) : url;
    const segments = path
        .replace(/^\/+|\/+$/g, '')
        .split('/')
        .filter(Boolean);
    if (segments.length === 0)
        return [];
    const crumbs = [{ name: 'Home', url: base || '/' }];
    let acc = base;
    segments.forEach((seg, i) => {
        acc = `${acc}/${seg}`;
        const isLast = i === segments.length - 1;
        crumbs.push({ name: isLast ? title : humanizeSegment(seg), url: acc });
    });
    return crumbs;
}
/** Extract FAQ pairs from common document shapes (faqItems / faqs / questions). */
function extractFaqItems(data, page) {
    const raw = data.faqItems ?? data.faqs ?? data.questions ?? page.faqItems;
    if (!Array.isArray(raw))
        return [];
    const items = [];
    for (const entry of raw) {
        if (!entry || typeof entry !== 'object')
            continue;
        const e = entry;
        const question = pickString(e.question, e.q, e.title);
        const answer = pickString(e.answer, e.a, e.body, e.text);
        if (question && answer)
            items.push({ question, answer });
    }
    return items;
}
function asStringArray(...candidates) {
    for (const c of candidates) {
        if (Array.isArray(c)) {
            const strings = c.filter((x) => typeof x === 'string' && x.trim().length > 0);
            if (strings.length > 0)
                return strings;
        }
        if (typeof c === 'string' && c.trim().length > 0)
            return [c];
    }
    return undefined;
}
/**
 * Build the additional entity node for Schema.org types the graph foundation
 * doesn't model directly (LocalBusiness, Service, Product, Event, Person, …),
 * linked to the page's WebPage node via `mainEntityOfPage`. Returns null for
 * foundation types (already represented as WebPage/Article nodes).
 */
function buildSpecializedEntity(type, doc, meta, site) {
    if (FOUNDATION_TYPES.has(type))
        return null;
    const data = (doc.data ?? {});
    const page = (doc.pageSettings ?? {});
    const siteUrl = trimTrailingSlash(site?.siteUrl ?? '');
    const node = {
        '@type': type,
        '@id': `${meta.url}#entity`,
        name: meta.title,
        url: meta.url,
        mainEntityOfPage: { '@id': meta.url },
    };
    if (meta.description)
        node.description = meta.description;
    if (meta.image)
        node.image = { '@id': meta.image };
    if (type === 'LocalBusiness' || type === 'Service') {
        const address = data.address ?? page.address;
        if (address && typeof address === 'object') {
            node.address = { '@type': 'PostalAddress', ...address };
        }
        const phone = pickString(data.phone, data.telephone, page.phone);
        if (phone)
            node.telephone = phone;
        const priceRange = pickString(data.priceRange, page.priceRange);
        if (priceRange)
            node.priceRange = priceRange;
        if (siteUrl)
            node.provider = { '@id': `${siteUrl}/#organization` };
    }
    return node;
}
/**
 * Compose a connected Schema.org `@graph` for a document. Stacks the WebSite +
 * Organization (+ sameAs / SearchAction) site nodes, the page node (WebPage or
 * Article/BlogPosting), a derived BreadcrumbList, FAQ `mainEntity`, and an
 * optional specialized entity node (LocalBusiness, Service, …) — so every page
 * ships a single graph that search engines and AI crawlers can traverse,
 * rather than an isolated flat object.
 *
 * Heuristic field mapping keeps default content working without per-collection
 * wiring. A doc-level `structuredData` override is honored verbatim (the editor
 * knows best).
 */
function composeJsonLd(type, doc, meta, site, config) {
    const data = (doc.data ?? {});
    const page = (doc.pageSettings ?? {});
    // Doc-level override: if structuredData is already populated, use it verbatim.
    if (doc.structuredData &&
        typeof doc.structuredData === 'object' &&
        Object.keys(doc.structuredData).length > 0) {
        const sd = doc.structuredData;
        return {
            '@context': sd['@context'] ?? 'https://schema.org',
            ...sd,
        };
    }
    const siteUrl = trimTrailingSlash(site?.siteUrl ?? '');
    const defaultLanguage = config?.i18n?.defaultLocale;
    const siteSchema = {
        siteName: site?.siteName ?? 'Site',
        siteUrl,
        siteDescription: site?.defaultMetaDescription,
        organizationName: site?.organization?.name ?? site?.siteName,
        organizationLogo: site?.organization?.logo,
        organizationSameAs: site?.organization?.sameAs,
        defaultLanguage,
    };
    const { datePublished, dateModified } = resolveContentDates({ doc, data, page });
    const authorName = pickString(data.authorName, data.author, data.author?.name);
    const faqItems = extractFaqItems(data, page);
    // GEO key takeaway → Schema.org `abstract` (a citable summary for AI engines).
    const keyTakeaway = pickString(page.keyTakeaway, data.keyTakeaway);
    const pageInput = {
        url: meta.url,
        title: meta.title,
        description: meta.description,
        type: schemaTypeToPageType(type),
        ...(datePublished ? { datePublished } : {}),
        ...(dateModified ? { dateModified } : {}),
        ...(authorName ? { author: { name: authorName } } : {}),
        ...(meta.image ? { featuredImage: { url: meta.image } } : {}),
        ...(faqItems.length > 0 ? { faqItems } : {}),
        ...(keyTakeaway ? { abstract: keyTakeaway } : {}),
        breadcrumbs: deriveBreadcrumbs(siteUrl, meta.url, meta.title),
        keywords: asStringArray(data.keywords, data.tags),
        articleSection: asStringArray(data.category, data.categories),
        language: defaultLanguage,
    };
    const graphObj = buildSchemaGraph(siteSchema, pageInput);
    const graph = [...graphObj['@graph']];
    const entity = buildSpecializedEntity(type, doc, meta, site);
    if (entity) {
        graph.push(entity);
        // Cross-link the WebPage node to its primary entity for traversal.
        const webPage = graph.find((n) => {
            const t = n['@type'];
            return t === 'WebPage' || (Array.isArray(t) && t.includes('WebPage'));
        });
        if (webPage)
            webPage.mainEntity = { '@id': entity['@id'] };
    }
    return { '@context': 'https://schema.org', '@graph': graph };
}
/**
 * Compose page meta + JSON-LD from a CMS document + collection config.
 * Pure function — safe to call in any runtime (Edge, Node, browser).
 */
export function composePageMeta(input) {
    const { doc, collection, config } = input;
    const site = config?.seo;
    const collectionSeo = collection?.seo;
    const data = (doc.data ?? {});
    const page = (doc.pageSettings ?? {});
    const contentDates = resolveContentDates({ doc, data, page });
    const siteUrl = input.siteUrl?.replace(/\/+$/, '') ?? site?.siteUrl?.replace(/\/+$/, '') ?? '';
    const siteName = site?.siteName ?? 'Site';
    const slug = (doc.slug ?? data.slug ?? '') || '';
    const url = resolveDocumentUrl({
        siteUrl,
        urlPrefix: collection?.urlPrefix,
        slug,
        data,
    });
    const schemaType = pickString(page.schemaType, data.schemaType, data.structuredDataType, page.structuredDataType, collectionSeo?.defaultSchemaType) ??
        input.defaultSchemaType ??
        (collection?.slug ? detectSchemaType(collection.slug) : 'WebPage');
    // Title: an explicit per-document SEO title always wins. Otherwise apply the
    // site-wide title template (Settings → SEO "Meta Defaults"), falling back to
    // the raw document title when no template is configured.
    const explicitTitle = pickString(page.metaTitle, data.metaTitle);
    const rawTitle = pickString(data.title, page.title) ?? 'Untitled';
    // Apply the site-wide template only when one is configured, so sites that
    // never set a template keep their existing bare-title behaviour.
    const hasTitleTemplate = typeof site?.titleTemplate === 'string' && site.titleTemplate.trim() !== '';
    const title = explicitTitle
        ? explicitTitle
        : hasTitleTemplate
            ? applySiteTitleTemplate(site.titleTemplate, {
                page: rawTitle,
                site: siteName,
                type: pickString(collection?.labels?.singular, schemaType),
                author: pickString(data.authorName, data.author),
                date: asIsoDate(doc.publishedAt ?? data.publishedAt)?.slice(0, 10),
                category: pickString(data.category, data.categories?.[0]),
                tag: pickString(data.tags?.[0], data.tag),
            })
            : rawTitle;
    // Description: per-document value wins, then the GEO key takeaway (a concise
    // citable summary), then other summaries, then the site-wide default.
    const description = pickString(page.metaDescription, data.metaDescription, page.keyTakeaway, data.keyTakeaway, data.excerpt, data.description, site?.defaultMetaDescription);
    // Canonical: accept both the cms-core (`canonical`) and plugin-seo / SEO
    // workspace (`canonicalUrl`) field names so a URL set in either surface is
    // honored on the public page.
    const canonical = pickString(page.canonical, data.canonical, page.canonicalUrl, data.canonicalUrl) ?? url;
    // Single source of truth for robots resolution (shared with the /seo/meta
    // render path). Honors the `robotsPolicy` select field, explicit
    // noindex/nofollow booleans, per-collection + site defaults, and the
    // non-production indexing guard so staging deploys aren't indexed.
    const robots = resolveRobotsDirectives({
        pageData: {
            robotsPolicy: page.robotsPolicy ?? data.robotsPolicy,
            // Accept the cms-core (`noIndex`/`noFollow`), lowercase SEO-workspace
            // (`noindex`/`nofollow`), and plugin-seo (`robotsNoindex`/`robotsNofollow`)
            // field names so a directive set in any surface is respected.
            noIndex: firstDefinedBool(page.noIndex, data.noIndex, data.noindex, data.robotsNoindex) ??
                collectionSeo?.defaultRobots?.noIndex,
            noFollow: firstDefinedBool(page.noFollow, data.noFollow, data.nofollow, data.robotsNofollow) ??
                collectionSeo?.defaultRobots?.noFollow,
        },
        settings: {
            defaultNoIndex: site?.robots?.defaultNoIndex,
            defaultNoFollow: site?.robots?.defaultNoFollow,
            noIndexNonProduction: site?.robots?.noIndexNonProduction,
        },
        environment: resolveSeoEnvironment(input.environment),
    });
    // A real, author-supplied image (used for the JSON-LD ImageObject — we don't
    // want the dynamic OG generator URL polluting structured data). Falls back to
    // the page's hero / featured / cover image (string or `{ url }`) before the
    // site default, so social cards use the page's own visual when set.
    const realImageUrl = pickString(page.ogImage, data.ogImage, asImageUrl(data.featuredImage), asImageUrl(data.heroImage), asImageUrl(data.coverImage), asImageUrl(data.image), site?.defaultOgImage) ?? undefined;
    const ogImageUrl = realImageUrl ??
        // The dynamic OG endpoint is served by the CMS API router, which is mounted
        // under /api/cms — not the site root — so the default must include it or it
        // 404s for social crawlers.
        (siteUrl ? `${siteUrl}/api/cms/og.png?title=${encodeURIComponent(title)}` : undefined);
    const metaInput = {
        title,
        description,
        url,
        siteName,
        canonical,
        noIndex: robots.noIndex,
        noFollow: robots.noFollow,
        type: schemaType === 'Article' || schemaType === 'BlogPosting' || schemaType === 'NewsArticle'
            ? 'article'
            : 'website',
        ogImage: ogImageUrl ? { url: ogImageUrl } : undefined,
        twitterSite: site?.twitterHandle,
        // Same resolved freshness dates the JSON-LD uses, so OG article times and
        // structured data never disagree (and editorial dates are honored).
        publishedTime: contentDates.datePublished,
        modifiedTime: contentDates.dateModified,
        author: pickString(data.authorName, data.author),
        locale: config?.i18n?.defaultLocale,
        metadataBase: siteUrl || undefined,
        alternates: input.alternates,
    };
    const meta = generateMetaTags(metaInput);
    let metaHtml = renderMetaTagsHtml(metaInput);
    let metadata = generateNextMetadata(metaInput);
    // Site-wide search-engine verification meta tags (Settings → SEO). These are
    // injected into every public page's head; values are escaped and emitted only
    // as safe `<meta>` tags.
    const verificationEntries = buildVerificationMetaEntries(site?.verification);
    if (verificationEntries.length > 0) {
        for (const e of verificationEntries) {
            const key = e.name ?? e.property;
            if (key)
                meta[key] = e.content;
        }
        const verificationHtml = renderVerificationMetaHtml(site?.verification);
        if (verificationHtml)
            metaHtml = `${metaHtml}\n${verificationHtml}`;
        const nextVerification = buildNextVerification(site?.verification);
        if (nextVerification)
            metadata = { ...metadata, verification: nextVerification };
    }
    const jsonLd = composeJsonLd(schemaType, doc, { title, description, url, image: realImageUrl }, site, config);
    const jsonLdHtml = jsonLd
        ? `<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, '\\u003c')}</script>`
        : '';
    return { url, title, description, canonical, jsonLd, jsonLdHtml, meta, metaHtml, metadata };
}
export { detectSchemaType };
//# sourceMappingURL=page-meta.js.map