/**
 * SEO scoring model.
 *
 * Pure, dependency-light functions that turn a page/post's metadata + content
 * into an explainable 0-100 score, and aggregate per-category signals into a
 * site-wide score. Nothing here is persisted directly — the audit engine
 * (Phase 2) calls these and snapshots the results into `SeoAuditRun` /
 * `SeoIssue` rows, while the Content SEO table recomputes page scores on read.
 *
 * The score is intentionally NOT a mystery number: every result carries a
 * `checks` array describing exactly which points were earned/lost and why, so
 * the admin UI can show editors what to fix. The unit tests in
 * `__tests__/seo/score.test.ts` are the source of truth for the weights.
 */
import { extractHeadings, stripHtmlTags } from './analysis.js';
/** Page score weights. Must sum to 100. */
export const PAGE_SEO_WEIGHTS = {
    metaTitlePresent: 10,
    metaTitleLength: 5,
    metaDescPresent: 10,
    metaDescLength: 5,
    focusKeyword: 8,
    canonical: 7,
    h1: 10,
    contentLength: 10,
    ogImage: 8,
    ogTitleDesc: 5,
    structuredData: 7,
    internalLinks: 5,
    indexable: 10,
};
/** Site score category weights (spec). Must sum to 100. */
export const SITE_SEO_WEIGHTS = {
    content: 30,
    technical: 25,
    linkRedirect: 20,
    structuredSocial: 15,
    performance: 10,
};
const MIN_CONTENT_WORDS = 300;
const IMPROVE_CONTENT_WORDS = 150;
function present(value) {
    return typeof value === 'string' && value.trim().length > 0;
}
/** Map a 0-100 score to a grade. Aligns with the Content SEO table colours. */
export function gradeForSeoScore(score) {
    if (score >= 80)
        return 'good';
    if (score >= 60)
        return 'fair';
    if (score >= 1)
        return 'poor';
    return 'critical';
}
/**
 * Count H1 headings in content that may be HTML or Markdown (case-insensitive
 * for HTML tags, ATX `#`/Setext `===` for Markdown), so markdown-bodied posts
 * aren't penalised for a "missing" H1 that is actually present.
 */
export function countH1(html) {
    return extractHeadings(html, 1).length;
}
function wordCountOf(content) {
    const text = stripHtmlTags(content);
    if (!text)
        return 0;
    return text.split(/\s+/).filter((w) => w.length > 0).length;
}
/**
 * Compute a page/post SEO score (0-100) with an explainable breakdown.
 *
 * Points are additive per {@link PAGE_SEO_WEIGHTS}; "warn" checks earn partial
 * credit. The total is clamped to 0-100 and rounded.
 */
export function calculatePageSeoScore(input) {
    const checks = [];
    const push = (id, label, max, status, message) => {
        const points = status === 'pass' ? max : status === 'warn' ? Math.round(max * 0.5) : 0;
        checks.push({ id, label, points, max, status, message });
    };
    // -- Meta title -----------------------------------------------------------
    const metaTitle = (input.metaTitle ?? '').trim();
    if (metaTitle.length === 0) {
        push('meta-title', 'Meta title', PAGE_SEO_WEIGHTS.metaTitlePresent, 'fail', 'No meta title set.');
        push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'fail', 'Add a 30–60 character meta title.');
    }
    else {
        push('meta-title', 'Meta title', PAGE_SEO_WEIGHTS.metaTitlePresent, 'pass', 'Meta title is set.');
        const len = metaTitle.length;
        if (len >= 30 && len <= 60) {
            push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'pass', `Meta title is ${len} characters — ideal.`);
        }
        else {
            push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'warn', `Meta title is ${len} characters. Aim for 30–60.`);
        }
    }
    // -- Meta description -----------------------------------------------------
    const metaDesc = (input.metaDescription ?? '').trim();
    if (metaDesc.length === 0) {
        push('meta-description', 'Meta description', PAGE_SEO_WEIGHTS.metaDescPresent, 'fail', 'No meta description set.');
        push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'fail', 'Add a 120–160 character description.');
    }
    else {
        push('meta-description', 'Meta description', PAGE_SEO_WEIGHTS.metaDescPresent, 'pass', 'Meta description is set.');
        const len = metaDesc.length;
        if (len >= 120 && len <= 160) {
            push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'pass', `Meta description is ${len} characters — ideal.`);
        }
        else {
            push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'warn', `Meta description is ${len} characters. Aim for 120–160.`);
        }
    }
    // -- Focus keyword --------------------------------------------------------
    push('focus-keyword', 'Focus keyword', PAGE_SEO_WEIGHTS.focusKeyword, present(input.focusKeyword) ? 'pass' : 'warn', present(input.focusKeyword) ? 'Focus keyword is set.' : 'No focus keyword set.');
    // -- Canonical ------------------------------------------------------------
    push('canonical', 'Canonical URL', PAGE_SEO_WEIGHTS.canonical, present(input.canonicalUrl) ? 'pass' : 'warn', present(input.canonicalUrl) ? 'Canonical URL is set.' : 'No canonical URL set.');
    // -- H1 -------------------------------------------------------------------
    const content = input.content ?? '';
    const h1Count = countH1(content);
    if (h1Count === 1) {
        push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, 'pass', 'Exactly one H1 heading.');
    }
    else if (h1Count === 0) {
        push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, content.length === 0 ? 'warn' : 'fail', 'No H1 heading found.');
    }
    else {
        push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, 'warn', `Found ${h1Count} H1 headings. Use exactly one.`);
    }
    // -- Content length -------------------------------------------------------
    const words = typeof input.wordCount === 'number' ? input.wordCount : wordCountOf(content);
    if (words >= MIN_CONTENT_WORDS) {
        push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'pass', `${words} words — meets the ${MIN_CONTENT_WORDS}-word minimum.`);
    }
    else if (words >= IMPROVE_CONTENT_WORDS) {
        push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'warn', `${words} words. Aim for ${MIN_CONTENT_WORDS}+.`);
    }
    else {
        push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'fail', `Only ${words} words. Aim for ${MIN_CONTENT_WORDS}+.`);
    }
    // -- OG image -------------------------------------------------------------
    push('og-image', 'Open Graph image', PAGE_SEO_WEIGHTS.ogImage, present(input.ogImage) ? 'pass' : 'warn', present(input.ogImage) ? 'OG image is set.' : 'No OG image — social shares show a placeholder.');
    // -- OG title / description ----------------------------------------------
    push('og-title-desc', 'Open Graph title & description', PAGE_SEO_WEIGHTS.ogTitleDesc, present(input.ogTitle) || present(input.ogDescription) ? 'pass' : 'warn', present(input.ogTitle) || present(input.ogDescription)
        ? 'OG title/description set or inherited.'
        : 'No OG title/description set.');
    // -- Structured data ------------------------------------------------------
    push('structured-data', 'Structured data', PAGE_SEO_WEIGHTS.structuredData, present(input.structuredDataType) ? 'pass' : 'warn', present(input.structuredDataType)
        ? `Schema type: ${input.structuredDataType}.`
        : 'No structured data type set.');
    // -- Internal links -------------------------------------------------------
    const broken = input.brokenInternalLinks ?? 0;
    push('internal-links', 'Internal links', PAGE_SEO_WEIGHTS.internalLinks, broken === 0 ? 'pass' : 'fail', broken === 0 ? 'No broken internal links.' : `${broken} broken internal link(s).`);
    // -- Indexability ---------------------------------------------------------
    const shouldIndex = input.shouldBeIndexable !== false;
    const accidentalNoindex = shouldIndex && input.noindex === true;
    push('indexable', 'Indexability', PAGE_SEO_WEIGHTS.indexable, accidentalNoindex ? 'fail' : 'pass', accidentalNoindex
        ? 'Page is set to noindex but should be indexable.'
        : 'Indexability is correct.');
    const earned = checks.reduce((sum, c) => sum + c.points, 0);
    const score = Math.max(0, Math.min(100, Math.round(earned)));
    return { score, grade: gradeForSeoScore(score), checks };
}
function clampScore(n) {
    if (!Number.isFinite(n))
        return 0;
    return Math.max(0, Math.min(100, n));
}
/**
 * Combine per-category sub-scores into a weighted 0-100 site score.
 *
 * Performance defaults to 70 (neutral) when not supplied, so sites without a
 * connected performance source aren't unfairly penalised — the weight is still
 * applied so connecting Search Console can only improve the signal's accuracy.
 */
export function calculateSiteSeoScore(input) {
    const categories = [
        {
            category: 'content',
            score: clampScore(input.content),
            weight: SITE_SEO_WEIGHTS.content,
            contribution: 0,
        },
        {
            category: 'technical',
            score: clampScore(input.technical),
            weight: SITE_SEO_WEIGHTS.technical,
            contribution: 0,
        },
        {
            category: 'linkRedirect',
            score: clampScore(input.linkRedirect),
            weight: SITE_SEO_WEIGHTS.linkRedirect,
            contribution: 0,
        },
        {
            category: 'structuredSocial',
            score: clampScore(input.structuredSocial),
            weight: SITE_SEO_WEIGHTS.structuredSocial,
            contribution: 0,
        },
        {
            category: 'performance',
            score: clampScore(input.performance ?? 70),
            weight: SITE_SEO_WEIGHTS.performance,
            contribution: 0,
        },
    ];
    let total = 0;
    for (const c of categories) {
        c.contribution = (c.score * c.weight) / 100;
        total += c.contribution;
    }
    const score = Math.max(0, Math.min(100, Math.round(total)));
    return { score, grade: gradeForSeoScore(score), breakdown: categories };
}
//# sourceMappingURL=score.js.map