<?php
/**
 * Tam Rank™ Helper Functions
 */

if (!defined('ABSPATH')) {
    exit;
}





/**
 * mbstring polyfills. The content/schema parsers use mb_* functions; on the rare
 * host without the mbstring extension these calls would fatal. Defined only when
 * the real functions are absent, so on normal hosts this block is a no-op. The
 * fallbacks are byte-oriented (not multibyte-correct) but prevent a hard crash.
 */
if (!function_exists('mb_strlen')) {
    function mb_strlen($string, $encoding = null) {
        return strlen((string) $string);
    }
}
if (!function_exists('mb_strtolower')) {
    function mb_strtolower($string, $encoding = null) {
        return strtolower((string) $string);
    }
}
if (!function_exists('mb_strtoupper')) {
    function mb_strtoupper($string, $encoding = null) {
        return strtoupper((string) $string);
    }
}
if (!function_exists('mb_encode_numericentity')) {
    function mb_encode_numericentity($string, $map = array(), $encoding = null, $hex = false) {
        return (string) $string;
    }
}

/**
 * Get meta title for post
 */
function tam_rank_get_meta_title($post_id = null) {
    if (!$post_id) {
        global $post;
        $post_id = $post ? $post->ID : 0;
    }
    
    if (!$post_id) {
        return '';
    }
    
    $meta_title = get_post_meta($post_id, '_tam_rank_meta_title', true);
    
    if (empty($meta_title)) {
        $meta_title = get_the_title($post_id);
    }
    
    return $meta_title;
}

/**
 * Get meta description for post
 */
function tam_rank_get_meta_description($post_id = null) {
    if (!$post_id) {
        global $post;
        $post_id = $post ? $post->ID : 0;
    }
    
    if (!$post_id) {
        return '';
    }
    
    return get_post_meta($post_id, '_tam_rank_meta_description', true);
}


/**
 * Check if post is noindex
 */
function tam_rank_is_noindex($post_id = null) {
    if (!$post_id) {
        global $post;
        $post_id = $post ? $post->ID : 0;
    }
    
    if (!$post_id) {
        return false;
    }
    
    return get_post_meta($post_id, '_tam_rank_noindex', true) === '1';
}

/**
 * Check if post is nofollow
 */
function tam_rank_is_nofollow($post_id = null) {
    if (!$post_id) {
        global $post;
        $post_id = $post ? $post->ID : 0;
    }
    
    if (!$post_id) {
        return false;
    }
    
    return get_post_meta($post_id, '_tam_rank_nofollow', true) === '1';
}



/**
 * Controleer of TamRank PRO actief is
 *
 * Wrapper rond licentiecontrole zodat nieuwe modules (zoals WebP) hierop kunnen steunen
 *
 * @return bool
 */
// Guarded: tamrank_is_pro() is also aliased in includes/license-helpers.php.
// Without this guard a load path where license-helpers.php loads first would
// fatal on redeclare. Both definitions resolve to tam_rank_premium_active.
if (!function_exists('tamrank_is_pro')) {
    function tamrank_is_pro() {
        if (function_exists('tam_rank_license_active')) {
            return tam_rank_license_active();
        }
        return false; // fallback: treated as free
    }
}




/**
 * Get plugin settings
 */
function tam_rank_get_settings() {
    return function_exists('tam_rank_cached_option')
        ? tam_rank_cached_option('tam_rank_general_settings', array())
        : get_option('tam_rank_general_settings', array());
}

/**
 * Get sitemap settings
 */
function tam_rank_get_sitemap_settings() {
    return function_exists('tam_rank_cached_option')
        ? tam_rank_cached_option('tam_rank_sitemap_settings', array())
        : get_option('tam_rank_sitemap_settings', array());
}






/**
 * Get 404 errors count
 *
 * Counts the hits that are still a problem. A URL with an active redirect is
 * fixed, so it no longer feeds the dashboard warning or the agent-facing total —
 * the same rule the 404 Monitor and the dashboard task use. Nothing is deleted:
 * deactivate the rule and the hits count again.
 */
function tam_rank_get_404_count() {
    global $wpdb;

    $table_name = $wpdb->prefix . 'tam_rank_404_log';

    if (!class_exists('TamRank_404_Resolution')) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the table/column identifier ({prefix}tam_rank_404_log) is interpolated; no user values; admin-context count read, caching not applicable
        return $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
    }

    $unresolved = TamRank_404_Resolution::unresolved_sql('url');

    if (!TamRank_404_Resolution::has_regex_rules()) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the table identifier ({prefix}tam_rank_404_log) and the plugin's own resolution predicate are interpolated; no user values; admin-context count read, caching not applicable
        return $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE $unresolved");
    }

    // Regex rules cannot be resolved in SQL: group the surviving hits per URL
    // and add up the ones no pattern answers. One query, not one per row.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the table identifier ({prefix}tam_rank_404_log) and the plugin's own resolution predicate are interpolated; no user values; admin-context count read, caching not applicable
    $rows = (array) $wpdb->get_results("SELECT url, COUNT(*) AS hits FROM $table_name WHERE $unresolved GROUP BY url");

    $hits_by_url = array();
    foreach ($rows as $row) {
        $hits_by_url[(string) $row->url] = (int) $row->hits;
    }

    $total = 0;
    foreach (TamRank_404_Resolution::drop_regex_resolved(array_keys($hits_by_url)) as $url) {
        $total += $hits_by_url[$url];
    }

    return $total;
}

/**
 * Get redirects count
 */
function tam_rank_get_redirects_count() {
    global $wpdb;
    
    $table_name = $wpdb->prefix . 'tam_rank_redirects';

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the table/column identifier ({prefix}tam_rank_redirects) is interpolated; no user values; admin-context count read, caching not applicable
    return $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE active = 1");
}






/**
 * Format date for display
 *
 * Geeft een LEGE STRING terug als er geen bruikbare datum is, en nadrukkelijk
 * geen opmaak: wat er dan op het scherm hoort te staan (een streepje, een zin,
 * niets) is een keuze van de aanroeper en niet van een formatteerfunctie.
 *
 * De guard is nodig omdat strtotime() bij een lege of onleesbare waarde false
 * teruggeeft, en date_i18n() bij false terugvalt op de HUIDIGE tijd. Zonder
 * guard zou deze functie dus een datum verzinnen die er echt uitziet. Zelfde
 * val als in de schematabel (class-schema-manager.php).
 *
 * @param mixed       $date   Datumstring, bij voorkeur uit current_time('mysql')
 * @param string|null $format Vrij formaat; standaard de datum- en tijdinstelling van de site
 * @return string Opgemaakte datum, of '' als er niets te tonen valt
 */
function tam_rank_format_date($date, $format = null) {
    if (!$format) {
        $format = get_option('date_format') . ' ' . get_option('time_format');
    }

    if (!is_scalar($date) || '' === trim((string) $date)) {
        return '';
    }

    $timestamp = strtotime((string) $date);

    return (false === $timestamp) ? '' : date_i18n($format, $timestamp);
}




/**
 * Get words count from content
 */
function tam_rank_get_word_count($content) {
    $content = wp_strip_all_tags($content);
    return str_word_count($content);
}


/**
 * The site-wide fallback image for social sharing.
 *
 * Last rung of the social-image chain: per-post override, then the featured
 * image, then this, then nothing. Reads the stored option straight rather than
 * going through TAMRANK_Schema_Settings::get_settings(), because that one
 * normalises the localbusiness and merchant subtrees on every read and this
 * runs on every front-end request.
 *
 * @return string Image URL, '' when the user never set one.
 */
function tam_rank_get_default_social_image() {
    $settings = get_option('tamrank_schema_global_settings', array());

    if (!is_array($settings) || empty($settings['default_social_image'])) {
        return '';
    }

    return (string) $settings['default_social_image'];
}


/**
 * The one place that decides which social meta tags a page carries.
 *
 * Everything that prints og:*, article:* or twitter:* reads this array; there is
 * no second list. Until 1.3.8.2 there were two - this function, which nothing in
 * production called, and a hand-written run of echo's in output_meta_tags() that
 * was missing half the set. That is why a site migrating from Yoast lost og:url,
 * og:site_name, og:locale and the image dimensions without anything reporting it.
 *
 * The caller resolves the page's own title, description, image and canonical
 * (output_meta_tags() does that for posts, terms and archives alike, including
 * the %variable% replacement) and hands them over in $context. What stays here
 * is the social-specific part: which tags exist, in which order, and the
 * fallbacks that are social and nothing else.
 *
 * @since 1.0.0
 * @param int|null $post_id Post ID, or null for the current post.
 * @param array    $context Optional resolved values: title, description, image, url.
 * @return array Tag name => value, in output order. Empty values are already dropped.
 */
function tam_rank_get_social_meta_tags($post_id = null, $context = array()) {
    if (!is_array($context)) {
        $context = array();
    }

    $context = array_merge(array(
        'title'       => '',
        'description' => '',
        'image'       => '',
        'url'         => '',
    ), $context);

    if (!$post_id && tam_rank_social_global_post_is_the_page()) {
        global $post;
        $post_id = $post ? (int) $post->ID : 0;
    }

    $post_id = (int) $post_id;

    // A singular view of a real post. Called straight (the harness, a template)
    // there is no query to ask yet, so a post id is enough on its own.
    $is_singular = $post_id > 0;
    if ($is_singular && function_exists('is_singular') && function_exists('did_action') && did_action('wp')) {
        $is_singular = is_singular();
    }

    $title       = $context['title'];
    $description = $context['description'];
    $image       = $context['image'];
    $url         = $context['url'];

    // Nothing handed over: resolve the same chain output_meta_tags() walks.
    if ($post_id) {
        if ('' === $title) {
            $social_title = get_post_meta($post_id, '_tam_rank_social_title', true);
            $title        = !empty($social_title) ? $social_title : tam_rank_get_meta_title($post_id);
        }
        if ('' === $description) {
            $social_description = get_post_meta($post_id, '_tam_rank_social_description', true);
            $description        = !empty($social_description) ? $social_description : tam_rank_get_meta_description($post_id);
        }
        if ('' === $image) {
            $social_image = get_post_meta($post_id, '_tam_rank_social_image', true);
            $image        = !empty($social_image) ? $social_image : (string) get_the_post_thumbnail_url($post_id, 'large');
        }
        if ('' === $url) {
            $canonical = get_post_meta($post_id, '_tam_rank_canonical', true);
            $url       = !empty($canonical) ? $canonical : (string) get_permalink($post_id);
        }
    }

    if ('' === $image) {
        $image = tam_rank_get_default_social_image();
    }

    if ('' === $url) {
        $url = tam_rank_get_social_archive_url();
    }

    $meta_tags = array();

    // Yoast's order, so a head diff between the two reads top to bottom.
    $og_type = tam_rank_get_social_og_type($post_id, $is_singular);

    $meta_tags['og:locale'] = tam_rank_get_social_locale();

    // A page that does not exist is not a thing to share. Yoast prints locale,
    // site_name and a title on a 404 and nothing more; og:type=website there
    // tells a crawler the miss is a real page, and tam_rank_get_social_archive_url()
    // leaves og:url empty for the same reason.
    if (!tam_rank_social_view_is_404()) {
        $meta_tags['og:type'] = $og_type;
    }

    if (!empty($title)) {
        $meta_tags['og:title'] = $title;
    }
    if (!empty($description)) {
        $meta_tags['og:description'] = $description;
    }
    if (!empty($url)) {
        $meta_tags['og:url'] = $url;
    }

    $site_name = get_bloginfo('name');
    if (!empty($site_name)) {
        $meta_tags['og:site_name'] = $site_name;
    }

    // Tied to og:type, not to "is this singular": article:* on something we just
    // called a website (the front page) is markup that contradicts itself.
    if ('article' === $og_type) {
        // Yoast's rule, and the reason it is not simply "always": a page that was
        // never touched after publishing gets no modified_time, and a post type
        // that is not an article gets no published_time. Printing more than Yoast
        // here is not parity, it is a second set of dates to keep true.
        if ('post' === get_post_type($post_id)) {
            $published = get_the_date('c', $post_id);
            if (!empty($published)) {
                $meta_tags['article:published_time'] = $published;
            }
        }

        // Later than publication, not merely different from it. A post that cron
        // published on schedule keeps a post_modified from before it went live,
        // and printing that as modified_time dates the page backwards. Compare
        // the GMT timestamps, so a timezone or a format never decides this.
        $modified      = get_the_modified_date('c', $post_id);
        $modified_gmt  = get_post_modified_time('U', true, $post_id);
        $published_gmt = get_post_time('U', true, $post_id);
        if (!empty($modified) && false !== $modified_gmt && false !== $published_gmt
            && (int) $modified_gmt > (int) $published_gmt) {
            $meta_tags['article:modified_time'] = $modified;
        }
    }

    if (!empty($image)) {
        $meta_tags['og:image'] = $image;

        // Real numbers or none. A hardcoded 1200x630 next to a 1598x896 image is
        // worse than no tag: the crawler crops to what we claim.
        $dimensions = tam_rank_get_social_image_dimensions($image);
        foreach ($dimensions as $key => $value) {
            $meta_tags[$key] = $value;
        }
    }

    // Only alongside an image, exactly as before 1.3.8.2: a large-image card
    // without a large image is a promise the page cannot keep.
    if (!empty($image)) {
        $meta_tags['twitter:card'] = 'summary_large_image';
    }

    if (!empty($title)) {
        $meta_tags['twitter:title'] = $title;
    }
    if (!empty($description)) {
        $meta_tags['twitter:description'] = $description;
    }
    if (!empty($image)) {
        $meta_tags['twitter:image'] = $image;
    }

    return $meta_tags;
}

/**
 * article on a singular view, website everywhere else.
 *
 * The front page is the exception even when it is a static page: it is the site,
 * not an article about something.
 *
 * @since 1.3.8.2
 * @param int  $post_id     Post ID.
 * @param bool $is_singular Is this a singular view?
 * @return string
 */
function tam_rank_get_social_og_type($post_id, $is_singular) {
    if (!$is_singular) {
        return 'website';
    }

    if (function_exists('is_front_page') && function_exists('did_action') && did_action('wp') && is_front_page()) {
        return 'website';
    }

    if ($post_id && (int) get_option('page_on_front') === (int) $post_id) {
        return 'website';
    }

    $post_type = get_post_type($post_id);
    if ($post_type && function_exists('is_post_type_viewable') && !is_post_type_viewable($post_type)) {
        return 'website';
    }

    return 'article';
}

/**
 * The locale the way Open Graph wants it: language_TERRITORY.
 *
 * WordPress serves a bare language code ('en', 'nl') as readily as a full one,
 * and og:locale does not take a bare code: a crawler that reads 'en' has to
 * guess a territory, and Facebook simply drops the value. This is Yoast's
 * mapping, so two SEO plugins on one page never print two different locales
 * for the same site: a handful of codes that do not become a locale by doubling
 * get a fixed territory, a two letter code doubles ('nl' becomes 'nl_NL'), a
 * variant like 'de_DE_formal' drops to its first two parts, and anything that
 * is still not a locale Facebook knows falls back to en_US.
 *
 * inLanguage in the schema graph is a different value with different rules
 * (BCP 47, where a bare 'en' is correct) and keeps its own source.
 *
 * @since 1.3.8.2
 * @return string
 */
function tam_rank_get_social_locale() {
    // The locales Facebook accepts. Anything outside this list is not a value
    // og:locale can carry, however valid a locale it is to WordPress.
    static $valid = array(
        'af_ZA', 'ak_GH', 'am_ET', 'ar_AR', 'as_IN', 'ay_BO', 'az_AZ', 'be_BY', 'bg_BG', 'bn_IN',
        'br_FR', 'bs_BA', 'ca_ES', 'cb_IQ', 'ck_US', 'co_FR', 'cs_CZ', 'cx_PH', 'cy_GB', 'da_DK',
        'de_DE', 'el_GR', 'en_GB', 'en_IN', 'en_PI', 'en_UD', 'en_US', 'eo_EO', 'es_CL', 'es_CO',
        'es_ES', 'es_LA', 'es_MX', 'es_VE', 'et_EE', 'eu_ES', 'fa_IR', 'fb_LT', 'ff_NG', 'fi_FI',
        'fo_FO', 'fr_CA', 'fr_FR', 'fy_NL', 'ga_IE', 'gl_ES', 'gn_PY', 'gu_IN', 'gx_GR', 'ha_NG',
        'he_IL', 'hi_IN', 'hr_HR', 'hu_HU', 'hy_AM', 'id_ID', 'ig_NG', 'is_IS', 'it_IT', 'ja_JP',
        'ja_KS', 'jv_ID', 'ka_GE', 'kk_KZ', 'km_KH', 'kn_IN', 'ko_KR', 'ku_TR', 'ky_KG', 'la_VA',
        'lg_UG', 'li_NL', 'ln_CD', 'lo_LA', 'lt_LT', 'lv_LV', 'mg_MG', 'mi_NZ', 'mk_MK', 'ml_IN',
        'mn_MN', 'mr_IN', 'ms_MY', 'mt_MT', 'my_MM', 'nb_NO', 'nd_ZW', 'ne_NP', 'nl_BE', 'nl_NL',
        'nn_NO', 'ny_MW', 'or_IN', 'pa_IN', 'pl_PL', 'ps_AF', 'pt_BR', 'pt_PT', 'qu_PE', 'rm_CH',
        'ro_RO', 'ru_RU', 'rw_RW', 'sa_IN', 'sc_IT', 'se_NO', 'si_LK', 'sk_SK', 'sl_SI', 'sn_ZW',
        'so_SO', 'sq_AL', 'sr_RS', 'sv_SE', 'sw_KE', 'sy_SY', 'sz_PL', 'ta_IN', 'te_IN', 'tg_TJ',
        'th_TH', 'tk_TM', 'tl_PH', 'tl_ST', 'tr_TR', 'tt_RU', 'tz_MA', 'uk_UA', 'ur_PK', 'uz_UZ',
        'vi_VN', 'wo_SN', 'xh_ZA', 'yi_DE', 'yo_NG', 'zh_CN', 'zh_HK', 'zh_TW', 'zu_ZA', 'zz_TR',
    );

    // The bare codes WordPress serves that do not become a locale by doubling.
    static $fixed = array(
        'ca' => 'ca_ES',
        'el' => 'el_GR',
        'en' => 'en_US',
        'et' => 'et_EE',
        'ja' => 'ja_JP',
        'sq' => 'sq_AL',
        'uk' => 'uk_UA',
        'vi' => 'vi_VN',
        'zh' => 'zh_CN',
    );

    $locale = function_exists('get_locale') ? (string) get_locale() : '';

    if (isset($fixed[$locale])) {
        $locale = $fixed[$locale];
    }

    if (2 === strlen($locale)) {
        $locale = strtolower($locale) . '_' . strtoupper($locale);
    }

    // A variant such as 'de_DE_formal' is the same locale with a tone of voice.
    if (!in_array($locale, $valid, true)) {
        $parts = explode('_', $locale);
        if (count($parts) > 2) {
            $locale = strtolower($parts[0]) . '_' . strtoupper($parts[1]);
        }
    }

    // A territory we do not know a locale for, such as 'de_AT': keep the
    // language and let it name its own territory.
    if (!in_array($locale, $valid, true)) {
        $language = strtolower(substr($locale, 0, 2));
        $locale   = $language . '_' . strtoupper($language);
    }

    if (!in_array($locale, $valid, true)) {
        $locale = 'en_US';
    }

    return $locale;
}

/**
 * Is there a query yet, and is this a 404 inside it?
 *
 * Guarded on did_action('wp') because the conditional tags lie before the main
 * query has run: called from a template or the harness there is no view to ask
 * about, and is_404() would answer false for a page that is very much there.
 *
 * @since 1.3.8.2
 * @return bool
 */
function tam_rank_social_view_is_404() {
    if (!function_exists('did_action') || !did_action('wp')) {
        return false;
    }

    return function_exists('is_404') && is_404();
}

/**
 * May we read the page from global $post?
 *
 * Outside a query (a template, the harness) global $post is the post being
 * worked on and the only thing there is to read. Inside one it is that post
 * only on a singular view: on an archive it is whichever post the loop reached
 * first, and until 1.3.8.2 the social set borrowed that post's permalink,
 * image, title and description for the archive it happened to appear on.
 *
 * @since 1.3.8.2
 * @return bool
 */
function tam_rank_social_global_post_is_the_page() {
    if (!function_exists('did_action') || !did_action('wp')) {
        return true;
    }

    return function_exists('is_singular') && is_singular();
}

/**
 * The URL a non-singular view is, or '' when it is not a place of its own.
 *
 * Only reached when the caller handed over no canonical and no permalink, so
 * this is the archive half of og:url. An archive is a real, indexable URL and
 * says so; a search page, a date archive and a 404 are not, and there the
 * honest answer is no og:url at all rather than the home page (which is what
 * this fell back to before 1.3.8.2, on a 404 included).
 *
 * TamRank prints no canonical of its own on archives, so the URL is built here:
 * from the term, the post type, the author or the posts page the query resolved
 * to, and from get_pagenum_link() on page two and up so /page/3/ does not claim
 * to be page one.
 *
 * @since 1.3.8.2
 * @return string
 */
function tam_rank_get_social_archive_url() {
    // No query to ask: the caller is a template or the harness, and the home
    // URL is the same answer it got before 1.3.8.2.
    if (!function_exists('did_action') || !did_action('wp')) {
        return function_exists('home_url') ? (string) home_url('/') : '';
    }

    if (is_404() || is_search() || is_date()) {
        return '';
    }

    $url = '';

    if (is_category() || is_tag() || is_tax()) {
        $term = get_queried_object();
        if ($term instanceof WP_Term) {
            $term_link = get_term_link($term);
            if (!is_wp_error($term_link)) {
                $url = (string) $term_link;
            }
        }
    } elseif (is_post_type_archive()) {
        $post_type = get_queried_object();
        if ($post_type instanceof WP_Post_Type) {
            $archive_link = get_post_type_archive_link($post_type->name);
            if ($archive_link) {
                $url = (string) $archive_link;
            }
        }
    } elseif (is_author()) {
        $author_id = get_queried_object_id();
        if ($author_id) {
            $url = (string) get_author_posts_url($author_id);
        }
    } elseif (is_home() || is_front_page()) {
        // The blog index: its own page when the site has one, the home page
        // when the posts live on the front page.
        $posts_page = is_home() ? (int) get_option('page_for_posts') : 0;
        $url = $posts_page ? (string) get_permalink($posts_page) : (string) home_url('/');
    }

    if ('' === $url) {
        return '';
    }

    $paged = (int) get_query_var('paged');
    if ($paged > 1) {
        $paged_url = get_pagenum_link($paged, false);
        if ($paged_url) {
            $url = (string) $paged_url;
        }
    }

    return $url;
}

/**
 * The real pixel size and mime type of a social image, or nothing at all.
 *
 * Reads the attachment metadata for the exact URL we print, resized variants
 * included: a 'large' thumbnail is 1024 wide, not the 1598 of the original, and
 * claiming the original's numbers makes the crawler crop. An image that is not a
 * local attachment (a CDN URL, an external image) has no metadata to read, and
 * then the honest answer is no width and height tags at all.
 *
 * @since 1.3.8.2
 * @param string $url Image URL.
 * @return array og:image:width/height/type, or an empty array.
 */
function tam_rank_get_social_image_dimensions($url) {
    $url = (string) $url;

    if ('' === $url || !function_exists('attachment_url_to_postid')) {
        return array();
    }

    $attachment_id = (int) attachment_url_to_postid($url);

    // A resized file (name-1024x576.png) is not in the posts table under that
    // name; the original is. Strip the suffix and ask again.
    if (!$attachment_id) {
        $stripped = preg_replace('/-\d+x\d+(\.[a-zA-Z0-9]+)$/', '$1', $url);
        if ($stripped && $stripped !== $url) {
            $attachment_id = (int) attachment_url_to_postid($stripped);
        }
    }

    if (!$attachment_id) {
        return array();
    }

    $metadata = wp_get_attachment_metadata($attachment_id);
    if (!is_array($metadata)) {
        return array();
    }

    $basename = wp_basename(wp_parse_url($url, PHP_URL_PATH));
    $width    = 0;
    $height   = 0;

    if (!empty($metadata['file']) && wp_basename($metadata['file']) === $basename) {
        $width  = isset($metadata['width']) ? (int) $metadata['width'] : 0;
        $height = isset($metadata['height']) ? (int) $metadata['height'] : 0;
    } elseif (!empty($metadata['sizes']) && is_array($metadata['sizes'])) {
        foreach ($metadata['sizes'] as $size) {
            if (isset($size['file']) && $size['file'] === $basename) {
                $width  = isset($size['width']) ? (int) $size['width'] : 0;
                $height = isset($size['height']) ? (int) $size['height'] : 0;
                break;
            }
        }
    }

    // URL we cannot place in the metadata: fall back to the original's size only
    // when the URL IS the original, never as a guess.
    if ((!$width || !$height) && empty($metadata['sizes'])) {
        $width  = isset($metadata['width']) ? (int) $metadata['width'] : 0;
        $height = isset($metadata['height']) ? (int) $metadata['height'] : 0;
    }

    if (!$width || !$height) {
        return array();
    }

    $tags = array(
        'og:image:width'  => (string) $width,
        'og:image:height' => (string) $height,
    );

    $mime = get_post_mime_type($attachment_id);
    if (!empty($mime)) {
        $tags['og:image:type'] = $mime;
    }

    return $tags;
}




/**
 * Get average SEO score
 *
 * Reads the same cached stats that the SEO Audit page displays, so the
 * dashboard widget and the audit page cannot drift. Falls back to a raw
 * AVG() over _tam_rank_audit_score only when the cached option is empty
 * (e.g. user has never run a bulk audit yet).
 */
function tam_rank_get_average_seo_score() {
    $cached = get_option('tam_rank_audit_overall_stats', array());
    if (is_array($cached) && isset($cached['stats']['average_score'])) {
        return (int) $cached['stats']['average_score'];
    }

    global $wpdb;
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on core postmeta ({$wpdb->postmeta}); admin dashboard fallback read (only when the cached stats option is empty), result is request-scoped
    $average = $wpdb->get_var(
        "SELECT AVG(CAST(meta_value AS UNSIGNED))
         FROM {$wpdb->postmeta}
         WHERE meta_key = '_tam_rank_audit_score'
         AND meta_value != ''"
    );

    return (int) round($average ?: 0);
}

/**
 * Get count of indexed pages
 */
function tam_rank_get_indexed_pages_count() {
    $args = array(
        'post_type' => array('post', 'page'),
        'post_status' => 'publish',
        'posts_per_page' => -1,
        // phpcs:ignore WordPress.DB.SlowDBQuery -- admin-only one-off count over the site's posts/pages; small dataset, run on dashboard widget render
        'meta_query' => array(
            'relation' => 'OR',
            array(
                'key' => '_tam_rank_noindex',
                'compare' => 'NOT EXISTS'
            ),
            array(
                'key' => '_tam_rank_noindex',
                'value' => '1',
                'compare' => '!='
            )
        )
    );
    
    $query = new WP_Query($args);
    return $query->found_posts;
}


/**
 * Shared SQL exclusion clauses for the audit/dashboard post counters.
 *
 * Single source of truth so the SEO Audit stat cards, the audit "Most Common Issues"
 * widget, the dashboard health counters and the dashboard quick-win suggestions all
 * count the SAME set of posts: skip noindex pages (when the setting is on) and posts
 * manually excluded from the audit.
 *
 * Returns a string of " AND ..." clauses to append to a WHERE that already aliases
 * {$wpdb->posts} as $alias. The string is built only from intval'd IDs and table
 * identifiers — it never contains user input, so it is safe to interpolate directly.
 *
 * @param string $alias Table alias used for {$wpdb->posts} in the calling query.
 * @return string
 */
function tam_rank_audit_exclusion_clauses( $alias = 'p' ) {
    global $wpdb;

    // Hard-sanitize the alias even though callers only pass literals.
    $alias = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $alias);
    if ($alias === '') {
        $alias = 'p';
    }

    $settings = tam_rank_cached_option('tam_rank_general_settings', array());
    $noindex  = isset($settings['audit_exclude_noindex']) ? (bool) $settings['audit_exclude_noindex'] : true;
    $excl_ids = get_option('tam_rank_audit_excluded_posts', array());

    $clauses = '';
    if ($noindex) {
        $clauses .= " AND NOT EXISTS (
            SELECT 1 FROM {$wpdb->postmeta} ni
            WHERE ni.post_id = {$alias}.ID AND ni.meta_key = '_tam_rank_noindex' AND ni.meta_value = '1'
        )";
    }
    if (!empty($excl_ids)) {
        $safe_ids = implode(',', array_map('intval', $excl_ids));
        $clauses .= " AND {$alias}.ID NOT IN ({$safe_ids})";
    }
    return $clauses;
}


/**
 * Get plugin statistics
 */
function tam_rank_get_statistics() {
    global $wpdb;
    
    $stats = array();
    
    // Posts with meta titles
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct count over core postmeta ({$wpdb->postmeta}); admin-context statistics read, caching not applicable
    $stats['posts_with_meta_titles'] = $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->postmeta}
         WHERE meta_key = '_tam_rank_meta_title'
         AND meta_value != ''"
    );

    // Posts with meta descriptions
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct count over core postmeta ({$wpdb->postmeta}); admin-context statistics read, caching not applicable
    $stats['posts_with_meta_descriptions'] = $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->postmeta}
         WHERE meta_key = '_tam_rank_meta_description'
         AND meta_value != ''"
    );

    // Posts with focus keywords
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct count over core postmeta ({$wpdb->postmeta}); admin-context statistics read, caching not applicable
    $stats['posts_with_focus_keywords'] = $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->postmeta}
         WHERE meta_key = '_tam_rank_focus_keyword'
         AND meta_value != ''"
    );

    // Noindex posts
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct count over core postmeta ({$wpdb->postmeta}); admin-context statistics read, caching not applicable
    $stats['noindex_posts'] = $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->postmeta}
         WHERE meta_key = '_tam_rank_noindex'
         AND meta_value = '1'"
    );
    
    return $stats;
}

/**
 * Get the global schema manager instance
 *
 * @return TAMRANK_Schema_Manager|null
 */
function tamrank_get_schema_manager() {
    global $tamrank_schema;
    return $tamrank_schema;
}

// =============================================================================
// AI BRANDPROFIEL FUNCTIES
// =============================================================================

/**
 * Haal het AI brand profiel op
 *
 * @return array Brand profiel data met standaard waarden
 */
function tamrank_get_ai_brand_profile() {
    $defaults = array(
        'name'            => '',
        'description'     => '',
        'tone'            => '',
        'tone_extra'      => '',
        'audience'        => '',
        'usps'            => array(),
        'forbidden_words' => array(),
    );

    $profile = get_option('tamrank_ai_brand_profile', array());

    // Merge met defaults om zeker te zijn dat alle keys bestaan
    return wp_parse_args($profile, $defaults);
}


/**
 * Controleer of er een brand profiel is ingesteld
 *
 * @return bool True als er minimaal één veld is ingevuld
 */
function tamrank_has_brand_profile() {
    $profile = tamrank_get_ai_brand_profile();

    return !empty($profile['name']) ||
           !empty($profile['description']) ||
           !empty($profile['tone']) ||
           !empty($profile['audience']) ||
           !empty($profile['usps']) ||
           !empty($profile['forbidden_words']);
}

/**
 * Haal het brand profiel op als array voor de AI payload
 * Dit formaat wordt direct in de API request meegestuurd
 *
 * @return array Brand profiel klaar voor AI payload
 */
function tamrank_get_brand_profile_for_payload() {
    $profile = tamrank_get_ai_brand_profile();

    return array(
        'name'            => $profile['name'],
        'description'     => $profile['description'],
        'tone'            => $profile['tone'],
        'tone_extra'      => $profile['tone_extra'],
        'audience'        => $profile['audience'],
        'usps'            => $profile['usps'],
        'forbidden_words' => $profile['forbidden_words'],
    );
}

// =============================================================================
// DYNAMISCHE VARIABELEN VOOR META TITEL EN BESCHRIJVING
// =============================================================================

/**
 * Krijg beschikbare dynamische variabelen
 *
 * @return array Associatieve array met variabele => beschrijving
 */
function tamrank_get_available_variables() {
    return array(
        '%title%'    => __('Page/post title', 'tam-rank'),
        '%sitename%' => __('Site name', 'tam-rank'),
        '%excerpt%'  => __('Excerpt/summary', 'tam-rank'),
        '%date%'     => __('Publication date', 'tam-rank'),
        '%category%' => __('Primary category', 'tam-rank'),
        '%tag%'      => __('Primary tag', 'tam-rank'),
    );
}

/**
 * Translate a task priority value for display.
 *
 * The stored priority value (also used as a CSS class) may be Dutch
 * ('hoog'/'middel'/'laag') or English ('high'/'medium'/'low'); this maps it to
 * a translated, capitalized label without altering the stored value.
 *
 * @param string $priority Stored priority value.
 * @return string Translated label.
 */
function tamrank_priority_label($priority) {
    $key = strtolower((string) $priority);
    $map = array(
        'hoog'   => __('High', 'tam-rank'),
        'high'   => __('High', 'tam-rank'),
        'middel' => __('Medium', 'tam-rank'),
        'medium' => __('Medium', 'tam-rank'),
        'laag'   => __('Low', 'tam-rank'),
        'low'    => __('Low', 'tam-rank'),
    );
    return isset($map[$key]) ? $map[$key] : ucfirst((string) $priority);
}

/**
 * Vervang dynamische variabelen in een tekst
 *
 * @param string $text De tekst met variabelen
 * @param int|null $post_id Het post ID (optioneel, gebruikt global $post als niet opgegeven)
 * @return string De tekst met vervangen variabelen
 */
function tamrank_replace_dynamic_variables($text, $post_id = null) {
    // PHP 8.1+ null-safe check - voorkom deprecation warnings
    if ($text === null || $text === '') {
        return $text ?? '';
    }

    // Zorg dat $text een string is
    $text = (string) $text;

    // Geen variabelen in de tekst? Geen verwerking nodig
    if (strpos($text, '%') === false) {
        return $text;
    }

    if (!$post_id) {
        global $post;
        $post_id = $post ? $post->ID : 0;
    }

    if (!$post_id) {
        return $text;
    }

    $post_obj = get_post($post_id);
    if (!$post_obj) {
        return $text;
    }

    // Bouw de vervangingen array
    $replacements = array(
        '%title%'    => get_the_title($post_id),
        '%sitename%' => get_bloginfo('name'),
        '%excerpt%'  => tamrank_get_post_excerpt($post_id),
        '%date%'     => get_the_date('', $post_id),
        '%category%' => tamrank_get_primary_category($post_id),
        '%tag%'      => tamrank_get_primary_tag($post_id),
    );

    // Vervang alle variabelen
    return str_replace(array_keys($replacements), array_values($replacements), $text);
}

/**
 * Krijg de excerpt van een post
 *
 * @param int $post_id Het post ID
 * @return string De excerpt
 */
function tamrank_get_post_excerpt($post_id) {
    $post = get_post($post_id);
    if (!$post) {
        return '';
    }

    // Eerst kijken of er een handmatige excerpt is
    if (!empty($post->post_excerpt)) {
        return wp_strip_all_tags($post->post_excerpt);
    }

    // Anders genereren uit de content
    $content = $post->post_content;
    $content = wp_strip_all_tags($content);
    $content = strip_shortcodes($content);

    return wp_trim_words($content, 25, '...');
}

/**
 * Krijg de primaire categorie van een post
 *
 * @param int $post_id Het post ID
 * @return string De naam van de primaire categorie
 */
function tamrank_get_primary_category($post_id) {
    $categories = get_the_category($post_id);

    if (empty($categories)) {
        return '';
    }

    // Yoast primary category support
    $primary_term_id = get_post_meta($post_id, '_yoast_wpseo_primary_category', true);
    if ($primary_term_id) {
        $primary_cat = get_term($primary_term_id, 'category');
        if ($primary_cat && !is_wp_error($primary_cat)) {
            return $primary_cat->name;
        }
    }

    // Anders eerste categorie
    return $categories[0]->name;
}

/**
 * Krijg de primaire tag van een post
 *
 * @param int $post_id Het post ID
 * @return string De naam van de primaire tag
 */
function tamrank_get_primary_tag($post_id) {
    $tags = get_the_tags($post_id);

    if (empty($tags) || is_wp_error($tags)) {
        return '';
    }

    return $tags[0]->name;
}

/**
 * Allowed HTML tags for inline SVG icons rendered from internal config.
 * Used by wp_kses() when echoing trusted SVG markup that we nevertheless
 * want to pass through an HTML filter as defense-in-depth.
 *
 * @return array
 */
function tam_rank_kses_svg() {
    return array(
        'svg'      => array( 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'xmlns' => true, 'class' => true, 'opacity' => true ),
        'path'     => array( 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'opacity' => true ),
        'circle'   => array( 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true ),
        'rect'     => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true ),
        'line'     => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'opacity' => true ),
        'polyline' => array( 'points' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'fill' => true, 'opacity' => true ),
        'polygon'  => array( 'points' => true, 'fill' => true, 'stroke' => true, 'opacity' => true ),
        'g'        => array( 'fill' => true, 'stroke' => true, 'transform' => true, 'opacity' => true ),
    );
}

/**
 * Mask an API key for safe display.
 *
 * Returns the first 4 characters followed by bullet characters and the last
 * 3 characters (e.g. `AIza••••••••••••xyz`). Empty strings pass through.
 * Short keys (< 8 chars) are fully masked to avoid exposing most of the key.
 *
 * @param string $key The raw API key.
 * @return string     HTML-safe masked key (bullet chars are raw Unicode, not entities).
 */
function tam_rank_mask_api_key( $key ) {
    $key = (string) $key;
    if ( '' === $key ) {
        return '';
    }
    if ( strlen( $key ) < 8 ) {
        return str_repeat( '•', strlen( $key ) );
    }
    return substr( $key, 0, 4 ) . str_repeat( '•', 12 ) . substr( $key, -3 );
}

/**
 * Ensure sensitive API key options are stored with autoload=no.
 *
 * WordPress loads every autoload=yes option into memory on each request via
 * wp_load_alloptions() and many "export all options" backup plugins include
 * that cache in their dumps. Keeping API keys out of the alloptions set
 * reduces the chance that a backup or debug-export leaks the key.
 *
 * Runs idempotently on admin_init — only rewrites the option when its
 * current autoload flag is not 'no'.
 */
function tam_rank_ensure_api_keys_not_autoloaded() {
    // 'tamrank_api_key' is a legacy option name read as a fallback by the API
    // client; cover it here so an upgraded site's old key cannot linger in the
    // autoloaded alloptions cache (and thus in backup/debug dumps).
    $sensitive_keys = array( 'tam_rank_api_key', 'tam_rank_pagespeed_api_key', 'tamrank_api_key' );

    foreach ( $sensitive_keys as $opt_name ) {
        $value = get_option( $opt_name, null );
        if ( null === $value || '' === $value ) {
            continue;
        }

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct read of the autoload flag on core options ({$wpdb->options}); option_name bound via prepare(); admin_init one-off read, caching not applicable
        $autoload = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
                $opt_name
            )
        );

        if ( 'no' !== $autoload ) {
            // Re-save with autoload=no. update_option with 3rd arg set is
            // a no-op if value is unchanged, so delete + add to force it.
            delete_option( $opt_name );
            add_option( $opt_name, $value, '', 'no' );
        }
    }
}
add_action( 'admin_init', 'tam_rank_ensure_api_keys_not_autoloaded' );

/**
 * Read the pending site-verification nonce, if one is live.
 *
 * Single source of truth for the ownership-proof challenge exposed during site
 * registration. Both public expose surfaces (the /.well-known text endpoint and
 * the tamrank/v1/site-verify REST route) read through this, and LicenseManager
 * writes the option ('tam_rank_verify_challenge', autoload=no, see
 * LicenseManager::OPTION_VERIFY_CHALLENGE). The value is a short-lived, one-time
 * token issued by the VPS — NOT the api_key, and worthless once verified/expired.
 *
 * Expiry is checked at read time (not just on the option TTL) so a stale or
 * object-cached row can never serve a token past its window.
 *
 * @return string The live nonce, or '' when no challenge is pending/valid.
 */
function tamrank_get_pending_verify_nonce() {
    $challenge = get_option( 'tam_rank_verify_challenge' );
    if ( ! is_array( $challenge ) || empty( $challenge['nonce'] ) || empty( $challenge['expires_at'] ) ) {
        return '';
    }
    if ( time() > (int) $challenge['expires_at'] ) {
        return '';
    }
    return (string) $challenge['nonce'];
}

/**
 * Validate that a user-supplied URL points at a public host (SSRF guard).
 *
 * wp_http_validate_url() rejects non-http(s) schemes, embedded credentials,
 * odd ports and the RFC-1918 private ranges (10/8, 172.16/12, 192.168/16,
 * 127/8) — but it does NOT reject the link-local range 169.254.0.0/16, which
 * is exactly the cloud-metadata endpoint (169.254.169.254 on AWS/GCP/Azure).
 * So after the WP check we resolve the host and reject every IP that fails
 * filter_var()'s NO_PRIV_RANGE | NO_RES_RANGE test (covers link-local,
 * loopback, multicast, reserved and private ranges, IPv4 and IPv6).
 *
 * @param string $url User-supplied URL.
 * @return true|WP_Error
 */
function tam_rank_validate_public_url( $url ) {
    $err = function () {
        return new WP_Error( 'unsafe_url', tamrank__( 'error_unsafe_url', 'The URL is not a valid public address.' ) );
    };

    if ( ! wp_http_validate_url( $url ) ) {
        return $err();
    }

    $host = wp_parse_url( $url, PHP_URL_HOST );
    if ( empty( $host ) ) {
        return $err();
    }
    $host = trim( $host, '[]' ); // strip IPv6 brackets

    // Collect the IP(s) the host resolves to.
    $ips = array();
    if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
        $ips[] = $host;
    } else {
        $v4 = gethostbynamel( $host );
        if ( is_array( $v4 ) ) {
            $ips = array_merge( $ips, $v4 );
        }
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record emits warnings on lookup failure; we handle the empty result below.
        $aaaa = @dns_get_record( $host, DNS_AAAA );
        if ( is_array( $aaaa ) ) {
            foreach ( $aaaa as $rec ) {
                if ( ! empty( $rec['ipv6'] ) ) {
                    $ips[] = $rec['ipv6'];
                }
            }
        }
    }

    if ( empty( $ips ) ) {
        return $err(); // unresolvable — refuse rather than let WP_Http guess
    }

    foreach ( $ips as $ip ) {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
            return $err();
        }
    }

    return true;
}

/**
 * SSRF-safe outbound GET for user-supplied URLs.
 *
 * Validates the URL with tam_rank_validate_public_url() (scheme + resolved-IP
 * private/reserved/link-local block), then fetches via wp_safe_remote_get()
 * with a capped redirection count so a 30x cannot bounce onto an internal
 * target. Always use this instead of wp_remote_get() when the URL originates
 * from user input. See Plugin-Security-Reference.md (A10 SSRF).
 *
 * @param string $url  User-supplied URL.
 * @param array  $args Optional wp_remote_get args, merged over safe defaults.
 * @return array|WP_Error Response array, or WP_Error if the URL is unsafe.
 */
function tam_rank_safe_remote_get( $url, $args = array() ) {
    $validation = tam_rank_validate_public_url( $url );
    if ( is_wp_error( $validation ) ) {
        return $validation;
    }

    $defaults = array(
        'timeout'     => 30,
        'redirection' => 3,
        'user-agent'  => 'Tam Rank SEO Audit Bot',
    );

    return wp_safe_remote_get( $url, array_merge( $defaults, $args ) );
}

/**
 * Request-scoped cache for get_option() calls.
 *
 * WordPress already caches autoloaded options via wp_load_alloptions(), but
 * (a) autoload=no options (like API keys) bypass that cache and hit the DB
 * each call, and (b) on hosts that disable the alloptions cache, even
 * autoloaded options cause repeated DB hits when read multiple times per
 * request. This helper gives a uniform request-scope cache that sits above
 * WP's own caching and is automatically invalidated when the option changes.
 *
 * Cache is invalidated via updated_option/added_option/deleted_option hooks
 * registered below.
 *
 * @param string $key     Option name.
 * @param mixed  $default Default value if option is missing.
 * @return mixed          Option value, or $default.
 */
function tam_rank_cached_option( $key, $default = false ) {
    global $tam_rank_option_cache;
    if ( ! is_array( $tam_rank_option_cache ) ) {
        $tam_rank_option_cache = array();
    }
    if ( ! array_key_exists( $key, $tam_rank_option_cache ) ) {
        $tam_rank_option_cache[ $key ] = get_option( $key, $default );
    }
    return $tam_rank_option_cache[ $key ];
}

/**
 * Invalidate the request-scope option cache for a specific key.
 * Hooked to option mutation actions so mid-request writes are never stale.
 *
 * @param string $option Option name that was mutated.
 */
function tam_rank_cached_option_invalidate( $option ) {
    global $tam_rank_option_cache;
    if ( isset( $tam_rank_option_cache[ $option ] ) ) {
        unset( $tam_rank_option_cache[ $option ] );
    }
}
add_action( 'updated_option', 'tam_rank_cached_option_invalidate' );
add_action( 'added_option',   'tam_rank_cached_option_invalidate' );
add_action( 'deleted_option', 'tam_rank_cached_option_invalidate' );

/**
 * Render an info-icon tooltip using the canonical .tr-tooltip component.
 *
 * Replaces the legacy .tam-info-icon[data-tooltip="…"] pattern with a
 * keyboard-accessible button + role="tooltip" bubble.
 *
 * @param string $text   Tooltip body text (plain text, may contain inline HTML).
 * @param array  $args   Optional. {
 *     @type string $position  Position modifier: 'top'|'bottom'|'right'|'left'. Default 'bottom'.
 *     @type string $size      Size modifier: 'sm'|'md'|'lg'. Default 'md'.
 *     @type string $variant   Theme variant: 'dark'|'light'. Default 'dark'.
 *     @type string $title     Optional bold title shown above the body text.
 *     @type bool   $allow_html  Treat $text as HTML (no esc_html). Default false.
 * }
 * @return string HTML markup.
 */
function tamrank_render_info_tooltip( $text, $args = array() ) {
    static $counter = 0;
    $counter++;
    $id = 'tt-info-' . $counter;

    $defaults = array(
        'position'   => 'bottom',
        'size'       => 'md',
        'variant'    => 'dark',
        'title'      => '',
        'allow_html' => false,
    );
    $args = array_merge( $defaults, $args );

    $classes = array( 'tr-tooltip' );
    if ( in_array( $args['position'], array( 'top', 'bottom', 'right', 'left' ), true ) && $args['position'] !== 'top' ) {
        $classes[] = 'tr-tooltip--' . $args['position'];
    }
    if ( in_array( $args['size'], array( 'sm', 'md', 'lg' ), true ) ) {
        $classes[] = 'tr-tooltip--' . $args['size'];
    }
    if ( $args['variant'] === 'light' ) {
        $classes[] = 'tr-tooltip--light';
    }

    $body = $args['allow_html'] ? $text : esc_html( $text );
    $title_html = '';
    if ( ! empty( $args['title'] ) ) {
        $title_html = '<strong>' . esc_html( $args['title'] ) . '</strong>';
    }

    $svg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">'
        . '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'
        . '</svg>';

    return sprintf(
        '<span class="%s"><button type="button" class="tr-tooltip-trigger" aria-describedby="%s">%s</button><span class="tr-tooltip__bubble" role="tooltip" id="%s">%s%s</span></span>',
        esc_attr( implode( ' ', $classes ) ),
        esc_attr( $id ),
        $svg,
        esc_attr( $id ),
        $title_html,
        $body
    );
}

/**
 * Locale-aware wrapper around WP human_time_diff().
 *
 * WP human_time_diff() uses the active WP locale, but TamRank exposes its own
 * language setting via tamrank_get_language() that may diverge from the WP
 * locale. This wrapper switches to the plugin language for the duration of the
 * call so output like "2 maanden geleden" / "2 Monate" matches the rest of the
 * plugin UI.
 *
 * @param int      $from Unix timestamp from which the difference begins.
 * @param int|null $to   Unix timestamp to which the difference ends. Default: now.
 * @return string Human-readable time difference in plugin language.
 */
function tamrank_human_time_diff( $from, $to = null ) {
    if ( null === $to ) {
        $to = current_time( 'timestamp' );
    }

    $lang = function_exists( 'tamrank_get_language' ) ? tamrank_get_language() : 'en';

    $locale_map = array(
        'nl' => 'nl_NL',
        'de' => 'de_DE',
        'fr' => 'fr_FR',
        'en' => 'en_US',
    );
    $target_locale = isset( $locale_map[ $lang ] ) ? $locale_map[ $lang ] : 'en_US';

    $switched = false;
    if ( function_exists( 'switch_to_locale' ) && get_locale() !== $target_locale ) {
        $switched = switch_to_locale( $target_locale );
    }

    $diff = human_time_diff( (int) $from, (int) $to );

    if ( $switched && function_exists( 'restore_previous_locale' ) ) {
        restore_previous_locale();
    }

    return $diff;
}

/**
 * Advanced robots directives (FREE, since 1.3.7)
 * -----------------------------------------------------------------------------
 * noindex / nofollow keep their own meta keys and their own output path so no
 * migration is needed. Everything below is additive: seven extra directives that
 * can be set per post/page/CPT and per taxonomy term.
 */

/**
 * Definition of the advanced robots directives.
 *
 * Single source of truth for the meta keys, the field type and the directive
 * name that ends up in the <meta name="robots"> tag. Used by the meta box, the
 * taxonomy screen, the save handlers and the front-end output.
 *
 * @return array<string,array{key:string,type:string,directive:string}>
 */
function tamrank_robots_directive_defs() {
    return array(
        'noarchive'         => array( 'key' => '_tam_rank_noarchive',         'type' => 'bool', 'directive' => 'noarchive' ),
        'nosnippet'         => array( 'key' => '_tam_rank_nosnippet',         'type' => 'bool', 'directive' => 'nosnippet' ),
        'noimageindex'      => array( 'key' => '_tam_rank_noimageindex',      'type' => 'bool', 'directive' => 'noimageindex' ),
        'notranslate'       => array( 'key' => '_tam_rank_notranslate',       'type' => 'bool', 'directive' => 'notranslate' ),
        'max_snippet'       => array( 'key' => '_tam_rank_max_snippet',       'type' => 'int',  'directive' => 'max-snippet' ),
        'max_image_preview' => array( 'key' => '_tam_rank_max_image_preview', 'type' => 'enum', 'directive' => 'max-image-preview' ),
        'max_video_preview' => array( 'key' => '_tam_rank_max_video_preview', 'type' => 'int',  'directive' => 'max-video-preview' ),
    );
}

/**
 * Allowed values for the max-image-preview enum.
 *
 * @return string[]
 */
function tamrank_robots_image_preview_values() {
    return array( 'none', 'standard', 'large' );
}

/**
 * Sanitize one raw directive value coming from $_POST.
 *
 * Returns '' when the value must not be stored at all (empty / invalid), so the
 * caller can delete the meta row instead of writing a meaningless value.
 *
 * @param string $name Directive name as used in tamrank_robots_directive_defs().
 * @param mixed  $raw  Raw, unslashed request value.
 * @return string Sanitized value, or '' when nothing should be stored.
 */
function tamrank_sanitize_robots_value( $name, $raw ) {
    $defs = tamrank_robots_directive_defs();
    if ( ! isset( $defs[ $name ] ) ) {
        return '';
    }

    switch ( $defs[ $name ]['type'] ) {
        case 'bool':
            return ! empty( $raw ) ? '1' : '';

        case 'int':
            $raw = trim( (string) $raw );
            if ( $raw === '' ) {
                return '';
            }
            // Only an optional single leading minus followed by digits. -1 means
            // "no limit"; anything below -1 is meaningless to search engines.
            if ( ! preg_match( '/^-?\d+$/', $raw ) ) {
                return '';
            }
            $int = (int) $raw;
            if ( $int < -1 ) {
                return '';
            }
            return (string) $int;

        case 'enum':
            $raw = sanitize_key( (string) $raw );
            return in_array( $raw, tamrank_robots_image_preview_values(), true ) ? $raw : '';
    }

    return '';
}

/**
 * Read the stored advanced directives for one object.
 *
 * @param int    $object_id   Post ID or term ID.
 * @param string $object_type 'post' or 'term'.
 * @return array<string,string> Directive name => stored value ('' when unset).
 */
function tamrank_get_robots_directive_values( $object_id, $object_type = 'post' ) {
    $object_id = (int) $object_id;
    $values    = array();

    foreach ( tamrank_robots_directive_defs() as $name => $def ) {
        if ( $object_id <= 0 ) {
            $values[ $name ] = '';
            continue;
        }
        $stored = ( $object_type === 'term' )
            ? get_term_meta( $object_id, $def['key'], true )
            : get_post_meta( $object_id, $def['key'], true );

        // Re-sanitize on read: a value written by an older version or by code
        // outside the plugin never reaches the output tag unchecked.
        $values[ $name ] = tamrank_sanitize_robots_value( $name, $stored );
    }

    return $values;
}

/**
 * Build the advanced directive strings for one object.
 *
 * @param int    $object_id   Post ID or term ID.
 * @param string $object_type 'post' or 'term'.
 * @return string[] e.g. array('noarchive', 'max-snippet:120')
 */
function tamrank_build_robots_directives( $object_id, $object_type = 'post' ) {
    $defs   = tamrank_robots_directive_defs();
    $values = tamrank_get_robots_directive_values( $object_id, $object_type );
    $out    = array();

    foreach ( $defs as $name => $def ) {
        $value = $values[ $name ];
        if ( $value === '' ) {
            continue;
        }
        if ( $def['type'] === 'bool' ) {
            $out[] = $def['directive'];
        } else {
            $out[] = $def['directive'] . ':' . $value;
        }
    }

    return $out;
}

/**
 * Persist the advanced directives from a $_POST-shaped array.
 *
 * The caller is responsible for the nonce and capability check; this helper only
 * sanitizes and writes. Field names in the request mirror the meta keys.
 *
 * @param int    $object_id   Post ID or term ID.
 * @param array  $source      Unslashed request data.
 * @param string $object_type 'post' or 'term'.
 * @return void
 */
function tamrank_save_robots_directives( $object_id, $source, $object_type = 'post' ) {
    $object_id = (int) $object_id;
    if ( $object_id <= 0 ) {
        return;
    }

    foreach ( tamrank_robots_directive_defs() as $name => $def ) {
        $raw   = isset( $source[ $def['key'] ] ) ? $source[ $def['key'] ] : '';
        $value = tamrank_sanitize_robots_value( $name, $raw );

        if ( $value === '' ) {
            if ( $object_type === 'term' ) {
                delete_term_meta( $object_id, $def['key'] );
            } else {
                delete_post_meta( $object_id, $def['key'] );
            }
            continue;
        }

        if ( $object_type === 'term' ) {
            update_term_meta( $object_id, $def['key'], $value );
        } else {
            update_post_meta( $object_id, $def['key'], $value );
        }
    }
}

/**
 * Render one robots directive as a checkbox with its name and a one-line
 * explanation underneath.
 *
 * Shared by every robots surface (post meta box, taxonomy term screen, advanced
 * panel) so all of them get the same green checkbox instead of WP-admin's blue
 * default: WP sets `appearance: none` on admin checkboxes, which makes CSS
 * `accent-color` a no-op, so the box has to be painted by us.
 *
 * @param array $args {
 *     @type string $id      Element id, must be unique on the screen.
 *     @type string $name    Field name (the meta key).
 *     @type bool   $checked Whether the box is ticked.
 *     @type string $label   Directive name, e.g. 'Noindex'.
 *     @type string $desc    One-line explanation, optional.
 * }
 * @return void
 */
function tamrank_render_robots_checkbox( $args ) {
    $args = array_merge(
        array(
            'id'      => '',
            'name'    => '',
            'checked' => false,
            'label'   => '',
            'desc'    => '',
        ),
        $args
    );
    ?>
    <label class="tr-robots__opt" for="<?php echo esc_attr( $args['id'] ); ?>">
        <input type="checkbox" class="tr-robots__check"
               id="<?php echo esc_attr( $args['id'] ); ?>"
               name="<?php echo esc_attr( $args['name'] ); ?>" value="1"
               <?php checked( $args['checked'] ); ?>>
        <span class="tr-robots__text">
            <span class="tr-robots__name"><?php echo esc_html( $args['label'] ); ?></span>
            <?php if ( $args['desc'] !== '' ) : ?>
                <span class="tr-robots__desc"><?php echo esc_html( $args['desc'] ); ?></span>
            <?php endif; ?>
        </span>
    </label>
    <?php
}

/**
 * Render the advanced robots directive fields.
 *
 * Shared by the post meta box and the taxonomy term screen so both surfaces stay
 * identical. Output only — the caller supplies the nonce and handles the save.
 *
 * Collapsed by default: seven extra directives are noise for the vast majority
 * of pages. It opens on load when something is set, and the summary carries a
 * counter, so a collapsed panel can never hide an active directive.
 *
 * @param array  $values    Directive name => stored value, from tamrank_get_robots_directive_values().
 * @param string $id_prefix Prefix for the element ids, to keep them unique per screen.
 * @return void
 */
function tamrank_render_advanced_robots_fields( $values, $id_prefix = 'post' ) {
    $defs      = tamrank_robots_directive_defs();
    $id_prefix = sanitize_key( $id_prefix );

    $labels = array(
        'noarchive'    => array( tamrank__( 'noarchive', 'Noarchive' ), tamrank__( 'noarchive_help', 'No cached copy of this page' ) ),
        'nosnippet'    => array( tamrank__( 'nosnippet', 'Nosnippet' ), tamrank__( 'nosnippet_help', 'No text snippet or video preview' ) ),
        'noimageindex' => array( tamrank__( 'noimageindex', 'Noimageindex' ), tamrank__( 'noimageindex_help', 'Do not index images on this page' ) ),
        'notranslate'  => array( tamrank__( 'notranslate', 'Notranslate' ), tamrank__( 'notranslate_help', 'Do not offer a translation of this page' ) ),
    );

    $not_set = tamrank__( 'robots_not_set', 'Not set' );

    // An empty string is "not set" for every directive, boolean or not, because
    // tamrank_sanitize_robots_value() normalises unticked boxes to ''.
    $active = 0;
    foreach ( array_keys( $defs ) as $name ) {
        if ( isset( $values[ $name ] ) && $values[ $name ] !== '' ) {
            $active++;
        }
    }
    ?>
    <?php
    /* translators: %d: number of advanced robots directives that are set. */
    $count_label = tamrank__( 'robots_advanced_active', '%d active' );
    ?>
    <details class="tr-robots-adv" data-tr-count-label="<?php echo esc_attr( $count_label ); ?>"<?php echo $active > 0 ? ' open' : ''; ?>>
        <summary class="tr-robots-adv__summary">
            <svg class="tr-robots-adv__chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor"
                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
                <path d="M6 3l5 5-5 5"/>
            </svg>
            <span class="tr-robots-adv__label"><?php echo esc_html( tamrank__( 'robots_advanced', 'Advanced directives' ) ); ?></span>
            <?php // Always rendered so tr-robots.js only has to toggle it; hidden while nothing is set. ?>
            <span class="tr-robots-adv__count"<?php echo $active > 0 ? '' : ' hidden'; ?>><?php echo esc_html( sprintf( $count_label, $active ) ); ?></span>
        </summary>

        <div class="tr-robots-adv__body">
            <p class="tr-robots-adv__hint">
                <?php echo esc_html( tamrank__( 'robots_advanced_help', 'Extra instructions for search engines. Leave these alone unless you know you need them.' ) ); ?>
            </p>

            <div class="tr-robots__grid">
                <?php foreach ( $labels as $name => $label ) : ?>
                    <?php
                    tamrank_render_robots_checkbox(
                        array(
                            'id'      => $id_prefix . '-' . $defs[ $name ]['key'],
                            'name'    => $defs[ $name ]['key'],
                            'checked' => $values[ $name ] === '1',
                            'label'   => $label[0],
                            'desc'    => $label[1],
                        )
                    );
                    ?>
                <?php endforeach; ?>
            </div>

            <div class="tr-robots-adv__limits">
                <?php
                $snippet_id = $id_prefix . '-' . $defs['max_snippet']['key'];
                $image_id   = $id_prefix . '-' . $defs['max_image_preview']['key'];
                $video_id   = $id_prefix . '-' . $defs['max_video_preview']['key'];
                ?>
                <div class="tr-robots-adv__limit">
                    <label for="<?php echo esc_attr( $snippet_id ); ?>">
                        <?php echo esc_html( tamrank__( 'max_snippet_label', 'Max snippet' ) ); ?>
                        <?php echo tamrank_render_info_tooltip( tamrank__( 'max_snippet_help', 'Maximum number of characters in a text snippet. Use -1 for no limit; leave empty to not set it.' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- returns escaped, plugin-generated tooltip markup (helper escapes text + attributes internally) ?>
                    </label>
                    <input type="number" step="1" min="-1" class="tam-input"
                           id="<?php echo esc_attr( $snippet_id ); ?>"
                           name="<?php echo esc_attr( $defs['max_snippet']['key'] ); ?>"
                           value="<?php echo esc_attr( $values['max_snippet'] ); ?>"
                           placeholder="<?php echo esc_attr( $not_set ); ?>">
                </div>

                <div class="tr-robots-adv__limit">
                    <label for="<?php echo esc_attr( $image_id ); ?>">
                        <?php echo esc_html( tamrank__( 'max_image_preview_label', 'Max image preview' ) ); ?>
                        <?php echo tamrank_render_info_tooltip( tamrank__( 'max_image_preview_help', 'Largest image preview search engines may show for this page.' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- returns escaped, plugin-generated tooltip markup (helper escapes text + attributes internally) ?>
                    </label>
                    <?php
                    $enum_labels = array(
                        'none'     => tamrank__( 'max_image_preview_none', 'None' ),
                        'standard' => tamrank__( 'max_image_preview_standard', 'Standard' ),
                        'large'    => tamrank__( 'max_image_preview_large', 'Large' ),
                    );
                    ?>
                    <select class="tam-input" id="<?php echo esc_attr( $image_id ); ?>"
                            name="<?php echo esc_attr( $defs['max_image_preview']['key'] ); ?>">
                        <option value=""><?php echo esc_html( $not_set ); ?></option>
                        <?php foreach ( tamrank_robots_image_preview_values() as $enum_value ) : ?>
                            <option value="<?php echo esc_attr( $enum_value ); ?>" <?php selected( $values['max_image_preview'], $enum_value ); ?>>
                                <?php echo esc_html( $enum_labels[ $enum_value ] ); ?>
                            </option>
                        <?php endforeach; ?>
                    </select>
                </div>

                <div class="tr-robots-adv__limit">
                    <label for="<?php echo esc_attr( $video_id ); ?>">
                        <?php echo esc_html( tamrank__( 'max_video_preview_label', 'Max video preview' ) ); ?>
                        <?php echo tamrank_render_info_tooltip( tamrank__( 'max_video_preview_help', 'Maximum number of seconds of a video preview. Use -1 for no limit; leave empty to not set it.' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- returns escaped, plugin-generated tooltip markup (helper escapes text + attributes internally) ?>
                    </label>
                    <input type="number" step="1" min="-1" class="tam-input"
                           id="<?php echo esc_attr( $video_id ); ?>"
                           name="<?php echo esc_attr( $defs['max_video_preview']['key'] ); ?>"
                           value="<?php echo esc_attr( $values['max_video_preview'] ); ?>"
                           placeholder="<?php echo esc_attr( $not_set ); ?>">
                </div>
            </div>
        </div>
    </details>
    <?php
}

/**
 * Whether the TamRank XML sitemap is switched on.
 *
 * Missing key means ON. That keeps every running installation behaving exactly as
 * before (no upgrade backfill, see the migration wizard notes) and matches what all
 * three competitors do with their own master switch.
 *
 * IMPORTANT: this deliberately does NOT read the legacy 'auto_generate' key. That key
 * never had an input field while it was in the sanitizer whitelist, so on every install
 * that ever saved the settings page it is stored as false. Reading it would silently
 * switch the sitemap off on those sites during a plain plugin update.
 *
 * @return bool True when the sitemap should be served.
 */
function tam_rank_sitemap_is_enabled() {
    $settings = tam_rank_cached_option( 'tam_rank_sitemap_settings', array() );

    if ( ! is_array( $settings ) || ! array_key_exists( 'sitemap_enabled', $settings ) ) {
        return true;
    }

    return (bool) $settings['sitemap_enabled'];
}

/**
 * Whether a Sitemap: value points at a sitemap TamRank itself serves.
 *
 * TamRank serves exactly two shapes: <home>/sitemap.xml and the <home>/sitemap-*.xml
 * sub-sitemaps. Everything else stays out of scope on purpose - core's
 * /wp-sitemap.xml, a same-host sitemap from another plugin (Yoast's
 * /sitemap_index.xml, say) and any external host are lines the user or another tool
 * put there, not ours to remove.
 *
 * Relative or otherwise host-less values are treated as NOT ours and are left alone.
 * The robots.txt spec requires an absolute URL in a Sitemap: directive, so such a line
 * is invalid either way; deleting it would mean guessing that we wrote it.
 *
 * @param string $url      Value of the Sitemap: directive.
 * @param string $own_host Lower-cased host of home_url().
 * @param string $own_base Path prefix of home_url() without trailing slash ('' at root).
 * @return bool
 */
function tam_rank_is_own_sitemap_url( $url, $own_host, $own_base ) {
    $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );

    if ( '' === $host || $host !== $own_host ) {
        return false;
    }

    return null !== tam_rank_own_sitemap_path( $url, $own_base );
}

/**
 * The sitemap path inside a URL, when that URL has the shape TamRank serves.
 *
 * Split off from tam_rank_is_own_sitemap_url() so the host test and the shape
 * test can be asked separately: the stale-Sitemap check below needs a URL that
 * has our shape but NOT our host, which is exactly a line left over from a
 * domain move.
 *
 * @param string $url      Value of the Sitemap: directive.
 * @param string $own_base Path prefix of home_url() without trailing slash ('' at root).
 * @return string|null Path below the install root ('/sitemap.xml'), or null when the
 *                     URL is not a sitemap TamRank would serve.
 */
function tam_rank_own_sitemap_path( $url, $own_base ) {
    $path = (string) wp_parse_url( $url, PHP_URL_PATH );
    if ( '' === $path ) {
        return null;
    }

    // A WordPress in a subdirectory serves /subdir/sitemap.xml; compare below the
    // install root, not below the domain root.
    if ( '' !== $own_base ) {
        if ( 0 !== strpos( $path, $own_base . '/' ) ) {
            return null;
        }
        $path = substr( $path, strlen( $own_base ) );
    }

    return 1 === preg_match( '#^/sitemap(-[^/]*)?\.xml$#i', $path ) ? $path : null;
}

/**
 * Remove Sitemap: directives that point at our own sitemap.
 *
 * robots.txt is free-form text in TamRank, so the stored value keeps advertising
 * <home>/sitemap.xml even after the master switch is turned off. Matching happens on
 * our own sitemap URL, not merely on our host: with the switch off, core's
 * /wp-sitemap.xml is exactly what a user would want to advertise instead, and a
 * same-host sitemap from another tool is none of our business either. That is also
 * what the UI promises - only "the Sitemap line to TamRank" is not served.
 *
 * NB: the migration path has its own, deliberately broader rule in
 * TamRank_Settings_Migrator::build_robots_target() - there we are rewriting an
 * imported robots.txt from another SEO plugin, where any same-host sitemap line IS the
 * line being replaced.
 *
 * @param string $robots_txt Raw robots.txt body.
 * @param bool   $force      Strip regardless of the master switch.
 * @return string
 */
function tam_rank_strip_own_sitemap_directives( $robots_txt, $force = false ) {
    if ( ! $force && tam_rank_sitemap_is_enabled() ) {
        return $robots_txt;
    }

    $own_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
    $own_base = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
    $lines    = preg_split( '/\R/', (string) $robots_txt );
    $kept     = array();

    foreach ( $lines as $line ) {
        if ( preg_match( '/^\s*sitemap\s*:\s*(\S+)/i', $line, $m ) ) {
            if ( tam_rank_is_own_sitemap_url( $m[1], $own_host, $own_base ) ) {
                continue;
            }
        }
        $kept[] = $line;
    }

    // Collapse the blank line that a removed directive can leave behind at the end.
    return rtrim( implode( "\n", $kept ) );
}

/**
 * Sitemap: lines that carry our own sitemap shape on somebody else's host.
 *
 * The Sitemap: line is composed once, at activation, out of home_url() (see
 * set_default_options()), and never again. Move the site - staging to live,
 * http to https, a domain change - and that line keeps advertising the address
 * the site had on the day the plugin was switched on. Nothing downstream
 * repairs it: the robots.txt body is free-form user text and TamRank does not
 * rewrite user text.
 *
 * So this only reports. Matching is deliberately narrow: the path has to be a
 * sitemap TamRank itself serves (/sitemap.xml or /sitemap-*.xml, below the
 * install root) while the host is not ours. A Sitemap: line pointing at some
 * other website's sitemap is a supported thing to have - the card says so in
 * as many words - and never matches here. A host-less (relative) value does
 * not match either; that line is invalid robots.txt whatever we think of it,
 * and we would be guessing that we wrote it.
 *
 * @param string $robots_txt Raw robots.txt body.
 * @return array[] One entry per stale line: line (index), url, offset (of the
 *                 URL inside the line) and fixed (the same sitemap on this site).
 */
function tam_rank_stale_sitemap_directives( $robots_txt ) {
    $own_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
    $own_base = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
    $stale    = array();

    if ( '' === $own_host ) {
        return $stale;
    }

    foreach ( preg_split( '/\R/', (string) $robots_txt ) as $index => $line ) {
        if ( ! preg_match( '/^\s*sitemap\s*:\s*(\S+)/i', $line, $m, PREG_OFFSET_CAPTURE ) ) {
            continue;
        }

        $url  = $m[1][0];
        $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );

        if ( '' === $host || $host === $own_host ) {
            continue;
        }

        $path = tam_rank_own_sitemap_path( $url, $own_base );
        if ( null === $path ) {
            continue;
        }

        $stale[] = array(
            'line'   => $index,
            'url'    => $url,
            'offset' => $m[1][1],
            'fixed'  => home_url( $path ),
        );
    }

    return $stale;
}

/**
 * The same robots.txt with every stale Sitemap: line pointed at this site.
 *
 * Only ever called from the button in the robots.txt card, never on a read: the
 * user presses it, the corrected text goes into the editor field and saves
 * through the normal settings form. The rest of every line is left exactly as
 * it was - only the URL token is swapped - so a trailing comment or unusual
 * spacing survives.
 *
 * @param string $robots_txt Raw robots.txt body.
 * @return string
 */
function tam_rank_fix_stale_sitemap_directives( $robots_txt ) {
    $stale = tam_rank_stale_sitemap_directives( $robots_txt );

    if ( empty( $stale ) ) {
        return (string) $robots_txt;
    }

    $lines = preg_split( '/\R/', (string) $robots_txt );

    foreach ( $stale as $hit ) {
        $lines[ $hit['line'] ] = substr_replace(
            $lines[ $hit['line'] ],
            $hit['fixed'],
            $hit['offset'],
            strlen( $hit['url'] )
        );
    }

    return implode( "\n", $lines );
}

/**
 * What the robots.txt card says about itself, in one shot.
 *
 * Presentation only. Every fact below comes from a check that already existed and
 * already decided something on this screen or in the serve path - the physical file,
 * the blog_public bypass in custom_robots_txt(), the stale Sitemap: report and the
 * sitemap master switch. Nothing here fetches, probes or detects anything new: no
 * outbound call, no filesystem write, no option.
 *
 * The deliberate limit: this describes what WORDPRESS HANDS OUT. It says nothing
 * about whether a crawler actually reaches the site - hosting, a CDN or a firewall
 * sit in front of WordPress and are invisible from here. The card carries that
 * caveat in as many words, because "all green" reads as "crawlers can get in" and
 * that is the one thing this screen cannot know.
 *
 * @return array {
 *     @type string $server   'file' | 'nonpublic' | 'tamrank' | 'wordpress'.
 *     @type string $file     Absolute path of the physical robots.txt, or ''.
 *     @type array  $stale    Stale Sitemap: lines, from tam_rank_stale_sitemap_directives().
 *     @type string $fixed    The body with those lines repaired, for the fix button.
 *     @type bool   $sitemap  Whether the TamRank XML sitemap is switched on.
 *     @type bool   $own_line Whether the body carries a Sitemap: line of our own shape.
 *     @type string $level    'ok' | 'off' | 'attention'.
 * }
 */
function tam_rank_robots_status() {
    $body     = (string) tam_rank_cached_option( 'tam_rank_robots_txt', '' );
    $file     = TamRank_Physical_Robots::find();
    $public   = (bool) get_option( 'blog_public' );
    $stale    = '' !== $body ? tam_rank_stale_sitemap_directives( $body ) : array();
    $sitemap  = tam_rank_sitemap_is_enabled();
    $own_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
    $own_base = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );

    // Does the body advertise a sitemap TamRank itself serves? Same test the strip
    // filter uses, so "your own Sitemap line" means the same thing in both places.
    $own_line = false;
    foreach ( preg_split( '/\R/', $body ) as $line ) {
        if ( preg_match( '/^\s*sitemap\s*:\s*(\S+)/i', $line, $m )
            && tam_rank_is_own_sitemap_url( $m[1], $own_host, $own_base ) ) {
            $own_line = true;
            break;
        }
    }

    // Who actually serves it, in the order the serve path decides: a physical file
    // never reaches WordPress at all, blog_public = 0 makes custom_robots_txt()
    // step aside, and an empty option leaves WordPress' own output in place.
    if ( '' !== $file ) {
        $server = 'file';
    } elseif ( ! $public ) {
        $server = 'nonpublic';
    } elseif ( '' !== $body ) {
        $server = 'tamrank';
    } else {
        $server = 'wordpress';
    }

    if ( 'file' === $server || 'nonpublic' === $server || $stale ) {
        $level = 'attention';
    } elseif ( 'tamrank' !== $server ) {
        $level = 'off';
    } else {
        $level = 'ok';
    }

    return array(
        'server'   => $server,
        'file'     => $file,
        'stale'    => $stale,
        'fixed'    => $stale ? tam_rank_fix_stale_sitemap_directives( $body ) : '',
        'sitemap'  => $sitemap,
        'own_line' => $own_line,
        'level'    => $level,
    );
}

/**
 * The status line at the top of the robots.txt section.
 *
 * One glance has to answer "do I need to do anything here?". robots.txt is
 * opt-out: leave it alone and search engines read everything, which is what
 * most sites want. So the healthy answer is "no" and it is ONE quiet line with
 * a green dot - deliberately not a card, because a green panel shouts about the
 * one state that needs no attention at all.
 *
 * Fact rows only render when there IS something to act on: a file that beats
 * this screen, a Sitemap: line left behind by a domain move, a sitemap switched
 * off. The three notices they replace are not gone - their texts are the detail
 * lines of those rows, and the stale-sitemap row carries the same one-click fix
 * button it always had (same id, same data attribute, same handler).
 *
 * @param array $status Output of tam_rank_robots_status().
 * @return void
 */
function tamrank_render_robots_status_line( $status ) {
    $own_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
    $rows     = array();

    // -- Row: a physical file wins over everything on this screen ----------
    if ( 'file' === $status['server'] ) {
        $rows[] = array(
            'level'  => 'warn',
            'label'  => tamrank__( 'robots_status_label_server', 'Who serves your robots.txt' ),
            'value'  => sprintf( tamrank__( 'robots_status_value_file', 'A file in your site root: %s' ), $status['file'] ),
            'detail' => sprintf(
                tamrank__( 'robots_physical_file', 'There is a robots.txt file in your site root (%s). As long as that file is there it beats anything you set in TamRank, so changes here have no effect. We import its contents, but we do not delete the file - remove it yourself over FTP or through your hosting panel if you want TamRank to manage your robots.txt.' ),
                $status['file']
            ),
        );
    }

    // -- Row: "discourage search engines" makes WordPress serve its own ----
    if ( 'nonpublic' === $status['server'] ) {
        $rows[] = array(
            'level'  => 'warn',
            'label'  => tamrank__( 'robots_status_label_server', 'Who serves your robots.txt' ),
            'value'  => tamrank__( 'robots_status_value_nonpublic', 'WordPress, because search engines are being discouraged' ),
            'detail' => tamrank__( 'robots_status_nonpublic', 'Under Settings - Reading this site asks search engines not to index it. While that is on, WordPress serves its own robots.txt and the text below is not live.' ),
        );
    }

    // -- Row: a Sitemap: line left behind by a domain move -----------------
    if ( $status['stale'] ) {
        $count = count( $status['stale'] );
        $rows[] = array(
            'level'  => 'warn',
            'label'  => tamrank__( 'robots_status_label_sitemap', 'Sitemap line' ),
            'value'  => 1 === $count
                ? sprintf( tamrank__( 'robots_status_value_stale', 'Points at %s, not at this site' ), $status['stale'][0]['url'] )
                : sprintf( tamrank__( 'robots_status_value_stale_plural', '%d lines point at another domain' ), $count ),
            'detail' => 1 === $count
                ? sprintf(
                    tamrank__( 'robots_sitemap_stale', 'The Sitemap: line in your robots.txt still points at %1$s, while this site now runs on %2$s. Search engines are being sent to the old address. TamRank does not change your robots.txt on its own - press the button and only that line is updated.' ),
                    $status['stale'][0]['url'],
                    $own_host
                )
                : sprintf(
                    tamrank__( 'robots_sitemap_stale_plural', '%1$d Sitemap: lines in your robots.txt still point at another domain, while this site now runs on %2$s. Search engines are being sent to the old address. TamRank does not change your robots.txt on its own - press the button and only those lines are updated.' ),
                    $count,
                    $own_host
                ),
            'button' => 1 === $count
                ? tamrank__( 'robots_sitemap_stale_fix', 'Update the Sitemap line' )
                : tamrank__( 'robots_sitemap_stale_fix_plural', 'Update the Sitemap lines' ),
            'fixed'  => $status['fixed'],
        );
    }

    // -- Row: our own Sitemap: line is filtered out of what is served ------
    if ( 'tamrank' === $status['server'] && ! $status['sitemap'] && $status['own_line'] ) {
        $rows[] = array(
            'level'  => 'info',
            'label'  => tamrank__( 'robots_status_label_sitemap', 'Sitemap line' ),
            'value'  => tamrank__( 'robots_status_value_suppressed', 'Your own Sitemap line is not served' ),
            'detail' => tamrank__( 'robots_sitemap_line_suppressed', 'Your XML sitemap is switched off, so the Sitemap: line pointing at TamRank is not served - even though it is still in the text below. A Sitemap: line pointing at another website is served as normal.' ),
        );
    }

    // -- The line itself ---------------------------------------------------
    // Attention states keep it to the verdict; the row underneath carries the
    // detail, so repeating it here would say the same thing twice.
    if ( 'attention' === $status['level'] ) {
        $line = 'file' === $status['server']
            ? tamrank__( 'robots_status_head_file', 'A file in your site root serves your robots.txt' )
            : ( 'nonpublic' === $status['server']
                ? tamrank__( 'robots_status_head_nonpublic', 'What you set here is not being served' )
                : tamrank__( 'robots_status_head_stale', 'Your Sitemap line points at another domain' ) );
    } elseif ( 'off' === $status['level'] ) {
        $line = tamrank__( 'robots_status_head_off', 'TamRank does not manage your robots.txt' ) . ' '
            . tamrank__( 'robots_status_sub_off', 'WordPress serves its own default. Switch it on below if you want to manage it yourself.' );
    } else {
        // Only claim what is actually in the text. No Sitemap: line of our own
        // is not a fault, so it gets its own wording rather than a silent omission.
        $line = tamrank__( 'robots_status_line_ok', 'Everything is fine.' ) . ' '
            . tamrank__( 'robots_status_sub_ok', 'TamRank serves your robots.txt.' ) . ' '
            . ( $status['own_line']
                ? tamrank__( 'robots_status_sub_sitemap_ok', 'The Sitemap line points at this site.' )
                : tamrank__( 'robots_status_sub_sitemap_none', 'There is no Sitemap line of your own in it.' ) );
    }
    ?>
    <div class="tr-robots-status tr-robots-status--<?php echo esc_attr( $status['level'] ); ?>">
        <p class="tr-robots-status__line">
            <span class="tr-robots-status__dot" aria-hidden="true"></span>
            <span class="tr-robots-status__text"><?php echo esc_html( $line ); ?></span>
        </p>

        <?php if ( $rows ) : ?>
            <ul class="tr-robots-status__facts">
                <?php foreach ( $rows as $row ) : ?>
                    <li class="tr-robots-status__fact tr-robots-status__fact--<?php echo esc_attr( $row['level'] ); ?>">
                        <p class="tr-robots-status__fact-head">
                            <span class="tr-robots-status__fact-label"><?php echo esc_html( $row['label'] ); ?></span>
                            <span class="tr-robots-status__fact-value"><?php echo esc_html( $row['value'] ); ?></span>
                        </p>
                        <p class="tr-robots-status__fact-detail"><?php echo esc_html( $row['detail'] ); ?></p>
                        <?php if ( ! empty( $row['button'] ) ) : ?>
                            <div class="tr-robots-status__fact-action" id="tr-robots-sitemap-stale">
                                <button type="button" class="tr-btn tr-btn--secondary tr-btn--sm" id="tr-robots-sitemap-fix"
                                    data-robots-fixed="<?php echo esc_attr( $row['fixed'] ); ?>">
                                    <?php echo esc_html( $row['button'] ); ?>
                                </button>
                            </div>
                        <?php endif; ?>
                    </li>
                <?php endforeach; ?>
            </ul>
        <?php endif; ?>
    </div>
    <?php
}

/**
 * Every transient key the sitemap uses, for a complete cache flush.
 *
 * Single source for the settings AJAX toggle, the migration write path and the
 * rollback, which all have to invalidate the exact same set.
 *
 * @return string[]
 */
function tam_rank_sitemap_transient_keys() {
    $keys = array(
        'tam_rank_sitemap_main',
        'tam_rank_sitemap_posts',
        'tam_rank_sitemap_pages',
        'tam_rank_sitemap_categories',
        'tam_rank_sitemap_tags',
    );

    foreach ( get_post_types( array( 'public' => true ), 'names' ) as $pt ) {
        $keys[] = 'tam_rank_sitemap_' . $pt;
    }
    foreach ( get_taxonomies( array( 'public' => true ), 'names' ) as $tax ) {
        $keys[] = 'tam_rank_sitemap_tax-' . $tax;
    }

    return array_values( array_unique( $keys ) );
}

/**
 * Delete every sitemap transient.
 *
 * @return void
 */
function tam_rank_flush_sitemap_transients() {
    foreach ( tam_rank_sitemap_transient_keys() as $key ) {
        delete_transient( $key );
    }
}

/**
 * Write an option without running its register_setting sanitizer.
 *
 * register_setting() attaches its sanitize_callback to the sanitize_option_{$option}
 * filter, and update_option() always runs through that filter - in AJAX requests too.
 * That is correct for form input, but wrong for the two callers here:
 *
 *  - the migration wizard writes values it has already validated itself, including
 *    legacy keys the strict form whitelist would drop;
 *  - the rollback writes back the exact pre-migration database state, which must come
 *    out byte-identical.
 *
 * The filter is removed for the duration of this one write and restored afterwards, so
 * anything later in the same request still gets the normal sanitizing behaviour.
 *
 * @param string $option Option name.
 * @param mixed  $value  Value to store verbatim.
 * @return bool Result of update_option().
 */
function tam_rank_write_option_raw( $option, $value ) {
    global $wp_filter;

    $hook  = 'sanitize_option_' . $option;
    $saved = isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ] : null;

    if ( null !== $saved ) {
        unset( $wp_filter[ $hook ] );
    }

    $result = update_option( $option, $value );

    if ( null !== $saved ) {
        $wp_filter[ $hook ] = $saved;
    }

    tam_rank_cached_option_invalidate( $option );

    return $result;
}

/**
 * Which archive types get a contextual noindex, and what they default to.
 *
 * Single source of truth for the three switches on the Technical SEO tab. The
 * defaults are not a taste question, they mirror what a migrant already had:
 *
 *  - date:   ON. Yoast ships noindex-archive-wpseo => true out of the box
 *            (measured in Yoast 27.4, inc/options/class-wpseo-option-titles.php),
 *            so somebody moving over from Yoast would otherwise silently gain a
 *            handful of indexable duplicates of his own blog.
 *  - author: OFF. Yoast's noindex-author-wpseo defaults to false, and on a
 *            one-author blog the author archive is often a real landing page.
 *            Turning it off for everybody would take away something people use.
 * Search results are NOT in this list on purpose. They were already noindexed
 * unconditionally before 1.3.8, and WordPress core noindexes them itself
 * (wp_robots_noindex_search(), hooked in default-filters.php). Measured on this
 * branch: with our own rule switched off the tag still reads "noindex, follow",
 * because core put it there. A switch that cannot deliver its "off" position is
 * worse than no switch, so search stays a rule and the card says so.
 *
 * @return array<string,bool> Setting key => default.
 */
function tam_rank_archive_noindex_defaults() {
    return array(
        'noindex_date_archives'   => true,
        'noindex_author_archives' => false,
    );
}

/**
 * Is one of the archive noindex switches on?
 *
 * @param string $key One of the keys from tam_rank_archive_noindex_defaults().
 * @return bool
 */
function tam_rank_archive_noindex_enabled( $key ) {
    $defaults = tam_rank_archive_noindex_defaults();

    if ( ! array_key_exists( $key, $defaults ) ) {
        return false;
    }

    $settings = tam_rank_cached_option( 'tam_rank_general_settings', array() );
    $settings = is_array( $settings ) ? $settings : array();

    if ( array_key_exists( $key, $settings ) ) {
        return (bool) $settings[ $key ];
    }

    return $defaults[ $key ];
}

/**
 * Does the query being served right now ask for a contextual noindex?
 *
 * Covers the three archive types that have no place of their own to carry a
 * setting: a date archive, an author archive and a search result page. Every
 * one of them is a listing that duplicates content which already has its own
 * indexable URL.
 *
 * Deliberately ONE function for both output channels. filter_robots() writes
 * the meta tag and tam_rank_send_robots_header() writes the X-Robots-Tag; the
 * two disagreeing is exactly the bug that was fixed in 1.3.7, so they read the
 * same answer here instead of each deciding for themselves.
 *
 * @return bool True when this request should be noindexed on context alone.
 */
function tam_rank_context_is_noindex_archive() {
    if ( is_search() ) {
        // Hard rule, unchanged since before 1.3.8. See the note on
        // tam_rank_archive_noindex_defaults() for why this has no switch.
        return true;
    }

    if ( is_author() ) {
        return tam_rank_archive_noindex_enabled( 'noindex_author_archives' );
    }

    if ( is_date() ) {
        return tam_rank_archive_noindex_enabled( 'noindex_date_archives' );
    }

    return false;
}

/**
 * Does a singular view of this post type carry a BreadcrumbList?
 *
 * One rule, every reader: the front-end renderer, the schema overview, the
 * URL tester, the meta box, the total-score column and the detector. They used
 * to hold ten copies of `array('post', 'page', 'product')` between them, which
 * is where the bug lived: a registered custom post type got the visible
 * breadcrumb — that has never had a post-type gate — but no BreadcrumbList, so
 * the page showed a trail Google could not read. The rule is now the same one
 * the visible breadcrumb follows: if a visitor can land on the singular view,
 * the trail they read goes into the structured data too.
 *
 * post, page and product are all viewable, so nothing about the existing
 * output changes; what changes is that a public CPT stops being an exception.
 *
 * @since 1.3.9
 * @param string $post_type Post type slug.
 * @return bool
 */
function tamrank_post_type_has_breadcrumb_schema( $post_type ) {
    $post_type = (string) $post_type;

    // is_post_type_viewable() needs a registered type; an unregistered slug is
    // not reachable on the front end, so it gets nothing.
    if ( $post_type === '' || ! post_type_exists( $post_type ) ) {
        return false;
    }

    return (bool) is_post_type_viewable( $post_type );
}

/**
 * Post meta key holding the last successfully fetched rendered page source.
 *
 * Written only during save_post or from the editor; the front end merely reads
 * it. The leading underscore makes it protected meta, so it stays out of the
 * custom fields panel and out of the REST API.
 *
 * @since 1.3.9
 */
if ( ! defined( 'TAMRANK_RENDERED_SOURCE_META' ) ) {
    define( 'TAMRANK_RENDERED_SOURCE_META', '_tamrank_schema_rendered_source' );
}

/**
 * Does this post need a separately fetched page source?
 *
 * The decision is made on the RENDERED content, never on raw post_content. With
 * the page builder active `the_content` already expands the shortcodes, and then
 * the rendered content is itself the source material. Only shortcodes that
 * survive the filter chain (builder inactive) or content rendered in the
 * template rather than in post_content ("thin content") need a second source.
 *
 * @since 1.3.9
 * @param WP_Post $post             Post object.
 * @param string  $rendered_content Result of the `the_content` filter chain.
 * @return bool
 */
function tamrank_needs_rendered_source( $post, $rendered_content ) {
    if ( ! $post instanceof WP_Post ) {
        return false;
    }

    $rendered_content = (string) $rendered_content;

    // Page builder shortcodes that survived the filter chain.
    if ( preg_match( '/\[et_pb_|\[vc_|\[fusion_/', $rendered_content ) ) {
        return true;
    }

    // Block and custom themes often render their content in the template, out of
    // post_content. Without a second source a FAQ page built from <details>
    // accordions has no material to build FAQPage schema from.
    return str_word_count( wp_strip_all_tags( $rendered_content ) ) < 50 && $post->post_status === 'publish';
}

/**
 * Does the content carry page builder shortcodes that nobody renders?
 *
 * Cheap and side-effect free: we do not run the shortcodes, we only ask whether
 * they are registered at all. An unregistered tag survives `the_content` and so
 * leaves the front end with unusable source material.
 *
 * @since 1.3.9
 * @param string $content Raw post_content.
 * @return bool
 */
function tamrank_has_unregistered_builder_shortcodes( $content ) {
    if ( ! preg_match_all( '/\[((?:et_pb_|vc_|fusion_)[a-z0-9_]+)/i', (string) $content, $matches ) ) {
        return false;
    }

    foreach ( array_unique( $matches[1] ) as $tag ) {
        if ( ! shortcode_exists( strtolower( $tag ) ) ) {
            return true;
        }
    }

    return false;
}

/**
 * Read the stored rendered page source.
 *
 * Falls back to the one-hour transient that versions before 1.3.9 wrote, so a
 * site that has not been re-saved yet does not suddenly lose its source.
 *
 * @since 1.3.9
 * @param int $post_id Post ID.
 * @return string Stored source, or an empty string.
 */
function tamrank_get_rendered_source( $post_id ) {
    $post_id = (int) $post_id;
    if ( $post_id <= 0 ) {
        return '';
    }

    $stored = get_post_meta( $post_id, TAMRANK_RENDERED_SOURCE_META, true );
    if ( is_string( $stored ) && $stored !== '' ) {
        return $stored;
    }

    $legacy = get_transient( 'tamrank_rendered_' . $post_id );

    return ( is_string( $legacy ) && $legacy !== '' ) ? $legacy : '';
}

/**
 * Store the rendered page source persistently.
 *
 * @since 1.3.9
 * @param int    $post_id Post ID.
 * @param string $html    Filtered page source.
 * @return bool Whether anything was stored.
 */
function tamrank_store_rendered_source( $post_id, $html ) {
    $post_id = (int) $post_id;
    $html    = is_string( $html ) ? $html : '';

    if ( $post_id <= 0 || trim( $html ) === '' ) {
        return false;
    }

    /**
     * Maximum size of the stored page source, in bytes.
     *
     * The meta is loaded along with the post on every view of it, so a
     * pathologically large page is truncated instead of carried around forever.
     *
     * @since 1.3.9
     * @param int $max_bytes Defaults to 1 MB.
     */
    $max_bytes = (int) apply_filters( 'tamrank_rendered_source_max_bytes', MB_IN_BYTES );
    if ( $max_bytes > 0 && strlen( $html ) > $max_bytes ) {
        $html = substr( $html, 0, $max_bytes );
    }

    // The metadata API unslashes what it is given; wp_slash() keeps the HTML exact.
    update_post_meta( $post_id, TAMRANK_RENDERED_SOURCE_META, wp_slash( $html ) );
    delete_transient( 'tamrank_rendered_source_fail_' . $post_id );

    return true;
}

/**
 * Remove the stored rendered page source, legacy transient included.
 *
 * @since 1.3.9
 * @param int $post_id Post ID.
 */
function tamrank_delete_rendered_source( $post_id ) {
    $post_id = (int) $post_id;
    if ( $post_id <= 0 ) {
        return;
    }

    delete_post_meta( $post_id, TAMRANK_RENDERED_SOURCE_META );
    delete_transient( 'tamrank_rendered_' . $post_id );
}

/**
 * Is the self-fetch for this post currently backed off?
 *
 * After a failed fetch (host blocks loopbacks, timeout) the next attempts are
 * skipped for five minutes, so a broken loopback cannot make every save in the
 * editor wait out the timeout again.
 *
 * @since 1.3.9
 * @param int $post_id Post ID.
 * @return bool
 */
function tamrank_rendered_source_fetch_blocked( $post_id ) {
    return (bool) get_transient( 'tamrank_rendered_source_fail_' . (int) $post_id );
}

/**
 * Mark a failed self-fetch, which starts the backoff.
 *
 * @since 1.3.9
 * @param int $post_id Post ID.
 */
function tamrank_mark_rendered_source_fetch_failed( $post_id ) {
    set_transient( 'tamrank_rendered_source_fail_' . (int) $post_id, 1, 5 * MINUTE_IN_SECONDS );
}

/**
 * Post meta key holding whether the page already carries someone else's FAQPage.
 *
 * '1' = yes, '0' = no, absent = never looked. Deliberately its own key and not a
 * corner of the stored rendered source: the answer needs the WHOLE page, and the
 * whole page is exactly what the front end stopped fetching in 1.3.8.2.
 *
 * @since 1.3.8.2
 * @return string
 */
function tamrank_external_faqpage_meta_key() {
    return '_tamrank_external_faqpage';
}

/**
 * Does this page already carry a FAQPage from the theme or another plugin?
 *
 * @since 1.3.8.2
 * @param int $post_id Post ID.
 * @return bool|null True or false once looked at, null when never checked.
 */
function tamrank_get_external_faqpage( $post_id ) {
    $waarde = get_post_meta( (int) $post_id, tamrank_external_faqpage_meta_key(), true );

    if ( '' === $waarde || null === $waarde ) {
        return null;
    }

    return '1' === (string) $waarde;
}

/**
 * Record the answer of one look at the whole page.
 *
 * @since 1.3.8.2
 * @param int  $post_id  Post ID.
 * @param bool $gevonden Was someone else's FAQPage found?
 */
function tamrank_store_external_faqpage( $post_id, $gevonden ) {
    update_post_meta( (int) $post_id, tamrank_external_faqpage_meta_key(), $gevonden ? '1' : '0' );
}

/**
 * Forget the recorded answer.
 *
 * @since 1.3.8.2
 * @param int $post_id Post ID.
 */
function tamrank_delete_external_faqpage( $post_id ) {
    delete_post_meta( (int) $post_id, tamrank_external_faqpage_meta_key() );
}

/**
 * Queue the one background look at the whole page.
 *
 * Never fetches anything itself: it only writes a scheduled event, so the
 * request that queues it - a visitor's pageview included - never waits. At most
 * one attempt per post per hour, so a site whose loopback is broken cannot
 * queue on every single pageview.
 *
 * @since 1.3.8.2
 * @param int  $post_id  Post ID.
 * @param bool $opnieuw  Force a fresh look (after an edit), ignoring the hourly cap.
 * @return bool True when an event was scheduled.
 */
function tamrank_queue_external_faqpage_scan( $post_id, $opnieuw = false ) {
    $post_id = (int) $post_id;
    if ( $post_id <= 0 ) {
        return false;
    }

    $rem = 'tamrank_extfaq_queued_' . $post_id;

    if ( $opnieuw ) {
        delete_transient( $rem );
    } elseif ( get_transient( $rem ) ) {
        return false;
    }

    if ( wp_next_scheduled( 'tamrank_scan_external_faqpage', array( $post_id ) ) ) {
        return false;
    }

    set_transient( $rem, 1, HOUR_IN_SECONDS );
    wp_schedule_single_event( time() + 30, 'tamrank_scan_external_faqpage', array( $post_id ) );

    return true;
}

/**
 * Option holding the primary schema type a site chose per post type.
 *
 * Empty on every site that never migrated: the detector's read of the page stays
 * in charge. The Yoast migration fills it from schema-article-type-<pt> and
 * schema-page-type-<pt>, which is where a Yoast site says "every page here is an
 * Article" - a statement the detector can never derive from content alone.
 *
 * @since 1.3.8.2
 * @return string
 */
function tamrank_schema_default_types_option() {
    return 'tamrank_schema_default_types';
}

/**
 * The primary schema type this site chose for a whole post type.
 *
 * @since 1.3.8.2
 * @param string $post_type Post type slug.
 * @return string Schema type, or '' when the site never set one.
 */
function tamrank_get_default_schema_type( $post_type ) {
    $post_type = (string) $post_type;
    if ('' === $post_type) {
        return '';
    }

    $defaults = get_option(tamrank_schema_default_types_option(), array());
    if (!is_array($defaults) || empty($defaults[$post_type])) {
        return '';
    }

    return (string) $defaults[$post_type];
}
