<?php
/**
 * TamRank settings source readers
 *
 * Reads robots.txt and sitemap configuration out of Yoast, Rank Math and AIOSEO.
 *
 * The robots.txt side is a fallback: the live loopback capture
 * (TamRank_Robots_Capture) is the primary source on a public site, because every one of
 * these plugins adds lines at render time that are nowhere in its options. The option
 * reading here is what runs when the capture fails or is not representative, and it is
 * also what feeds the wizard preview and the category counts.
 *
 * The sitemap side has no live equivalent and is always read from options.
 */

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

class TamRank_Settings_Source {

    /** @var string Source plugin slug: yoast|rankmath|aioseo. */
    private $plugin;

    /**
     * @param string $plugin Source plugin slug.
     */
    public function __construct( $plugin ) {
        $this->plugin = (string) $plugin;
    }

    /* ---------------------------------------------------------------------
     * robots.txt
     * ------------------------------------------------------------------ */

    /**
     * Reconstruct the source plugin's robots.txt from its options.
     *
     * @return array {
     *     @type string $content  Reconstructed robots.txt, '' when there is nothing.
     *     @type array  $notes    Machine-readable note keys for the report.
     * }
     */
    public function read_robots_from_options() {
        switch ( $this->plugin ) {
            case 'yoast':
                return $this->read_robots_yoast();
            case 'rankmath':
                return $this->read_robots_rankmath();
            case 'aioseo':
                return $this->read_robots_aioseo();
        }

        return array( 'content' => '', 'notes' => array() );
    }

    /**
     * Yoast has no robots.txt option at all - it only writes a physical file. What it
     * does have is a render-time filter that strips WordPress' default block and adds
     * its own. Reconstruct that block here.
     *
     * Only three of the six deny_* options actually produce robots.txt rules in 27.4:
     * deny_search_crawling, deny_wp_json_crawling and deny_adsbot_crawling
     * (src/integrations/front-end/robots-txt-integration.php:69-79). deny_ccbot_crawling,
     * deny_google_extended_crawling and deny_gptbot_crawling exist as options and have a
     * settings UI, but nothing reads them to build rules - measured, not assumed.
     * Importing them anyway would add rules that were never live on the source.
     *
     * The first two are hooked only when ! is_multisite(), so the reconstruction mirrors
     * that guard.
     *
     * @return array
     */
    private function read_robots_yoast() {
        $options = get_option( 'wpseo', array() );
        $options = is_array( $options ) ? $options : array();
        $notes   = array();

        $group = array();
        if ( ! empty( $options['deny_search_crawling'] ) && ! is_multisite() ) {
            $group[] = 'Disallow: /?s=';
            $group[] = 'Disallow: /page/*/?s=';
            $group[] = 'Disallow: /search/';
        }
        if ( ! empty( $options['deny_wp_json_crawling'] ) && ! is_multisite() ) {
            $group[] = 'Disallow: /wp-json/';
            $group[] = 'Disallow: /?rest_route=';
        }

        $lines = array( 'User-agent: *' );
        $lines = array_merge( $lines, $group ?: array( 'Disallow:' ) );

        if ( ! empty( $options['deny_adsbot_crawling'] ) && ! is_multisite() ) {
            $lines[] = '';
            $lines[] = 'User-agent: AdsBot';
            $lines[] = 'Disallow: /';
        }

        if ( ! isset( $options['enable_xml_sitemap'] ) || ! empty( $options['enable_xml_sitemap'] ) ) {
            $lines[] = '';
            $lines[] = 'Sitemap: ' . home_url( '/sitemap_index.xml' );
        }

        if ( is_multisite() ) {
            $notes[] = 'yoast_multisite_deny_skipped';
        }

        return array( 'content' => implode( "\n", $lines ), 'notes' => $notes );
    }

    /**
     * Rank Math keeps one flat string.
     *
     * @return array
     */
    private function read_robots_rankmath() {
        $general = get_option( 'rank-math-options-general', array() );
        $general = is_array( $general ) ? $general : array();
        $content = isset( $general['robots_txt_content'] ) ? (string) $general['robots_txt_content'] : '';

        return array(
            'content' => trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ),
            'notes'   => array(),
        );
    }

    /**
     * AIOSEO stores structured rules, each one itself a JSON string, inside an option
     * that is a JSON string with HTML-entity encoded values.
     *
     * Two rule families:
     *  - normal rules, only applied when tools.robots.enable is true;
     *  - search-appearance rules (a 'bot' or 'preventCrawling' key), which AIOSEO
     *    applies always, even with the toggle off. Measured on a live site.
     *
     * Before 4.4.2 the keys were 'rule' and 'directoryPath'; both shapes are read.
     *
     * @return array
     */
    private function read_robots_aioseo() {
        $decoded = $this->aioseo_options();
        $notes   = array();

        $enabled = ! empty( $decoded['tools']['robots']['enable'] );
        $rules   = isset( $decoded['tools']['robots']['rules'] ) && is_array( $decoded['tools']['robots']['rules'] )
            ? $decoded['tools']['robots']['rules']
            : array();

        $own_rules = array();
        $sa_rules  = array();

        foreach ( $rules as $raw ) {
            $rule = is_array( $raw ) ? $raw : json_decode( (string) $raw, true );
            if ( ! is_array( $rule ) ) {
                continue;
            }

            $agent     = isset( $rule['userAgent'] ) ? html_entity_decode( (string) $rule['userAgent'], ENT_QUOTES, 'UTF-8' ) : '';
            $directive = isset( $rule['directive'] ) ? (string) $rule['directive'] : ( isset( $rule['rule'] ) ? (string) $rule['rule'] : '' );
            $value     = isset( $rule['fieldValue'] ) ? (string) $rule['fieldValue'] : ( isset( $rule['directoryPath'] ) ? (string) $rule['directoryPath'] : '' );
            $value     = html_entity_decode( $value, ENT_QUOTES, 'UTF-8' );

            if ( '' === $agent || '' === $directive || '' === $value ) {
                continue;
            }

            $is_sa = ! empty( $rule['bot'] ) || ! empty( $rule['preventCrawling'] );
            $entry = array(
                'agent'     => $agent,
                'directive' => strtolower( $directive ),
                'value'     => $value,
            );

            if ( $is_sa ) {
                $sa_rules[] = $entry;
            } elseif ( $enabled ) {
                $own_rules[] = $entry;
            }
        }

        if ( ! $enabled && ! empty( $rules ) ) {
            $notes[] = 'aioseo_own_rules_disabled';
        }
        if ( ! empty( $sa_rules ) ) {
            $notes[] = 'aioseo_search_appearance_rules';
        }

        $content = $this->stringify_rules( array_merge( $own_rules, $sa_rules ) );

        return array(
            'content'             => $content,
            'notes'               => $notes,
            'search_appearance'   => $this->stringify_rules( $sa_rules ),
        );
    }

    /**
     * Group rules by user agent and render them as robots.txt text.
     *
     * The exact line order of AIOSEO's own mergeRules() is not reproduced; the rules
     * themselves are.
     *
     * @param array $rules List of ['agent','directive','value'].
     * @return string
     */
    private function stringify_rules( array $rules ) {
        if ( empty( $rules ) ) {
            return '';
        }

        $grouped = array();
        foreach ( $rules as $rule ) {
            $grouped[ $rule['agent'] ][] = ucfirst( $rule['directive'] ) . ': ' . $rule['value'];
        }

        $blocks = array();
        foreach ( $grouped as $agent => $lines ) {
            $blocks[] = 'User-agent: ' . $agent . "\n" . implode( "\n", array_unique( $lines ) );
        }

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

    /* ---------------------------------------------------------------------
     * sitemap configuration
     * ------------------------------------------------------------------ */

    /**
     * Read the source plugin's sitemap configuration.
     *
     * @return array {
     *     @type bool  $enabled     Master switch on the source.
     *     @type array $post_types  slug => bool, only for slugs the source has an opinion on.
     *     @type array $taxonomies  slug => bool, idem.
     *     @type array $notes       Machine-readable note keys.
     * }
     */
    public function read_sitemap_config() {
        switch ( $this->plugin ) {
            case 'yoast':
                return $this->read_sitemap_yoast();
            case 'rankmath':
                return $this->read_sitemap_rankmath();
            case 'aioseo':
                return $this->read_sitemap_aioseo();
        }

        return array( 'enabled' => true, 'post_types' => array(), 'taxonomies' => array(), 'notes' => array(), 'unmappable' => array() );
    }

    /**
     * Yoast: a post type is in the sitemap when it is not disabled AND not noindexed.
     * Both matter - disable-attachment defaults to true and disable-author/date carry
     * the archive switches. A taxonomy drops out on noindex-tax-<tax>.
     *
     * @return array
     */
    private function read_sitemap_yoast() {
        $wpseo  = get_option( 'wpseo', array() );
        $titles = get_option( 'wpseo_titles', array() );
        $wpseo  = is_array( $wpseo ) ? $wpseo : array();
        $titles = is_array( $titles ) ? $titles : array();

        $post_types = array();
        foreach ( self::public_post_types() as $slug ) {
            $disabled = ! empty( $titles[ 'disable-' . $slug ] );
            $noindex  = ! empty( $titles[ 'noindex-' . $slug ] );
            $post_types[ $slug ] = ! ( $disabled || $noindex );
        }

        $taxonomies = array();
        foreach ( self::public_taxonomies() as $slug ) {
            $taxonomies[ $slug ] = empty( $titles[ 'noindex-tax-' . $slug ] );
        }

        return array(
            'enabled'     => ! isset( $wpseo['enable_xml_sitemap'] ) || ! empty( $wpseo['enable_xml_sitemap'] ),
            'post_types'  => $post_types,
            'taxonomies'  => $taxonomies,
            'notes'       => array(),
            'unmappable'  => $this->collect_unmappable( array(
                'attachment' => ! empty( $titles['disable-attachment'] ),
                'author'     => ! empty( $titles['disable-author'] ) || ! empty( $titles['noindex-author-wpseo'] ),
                'date'       => ! empty( $titles['disable-date'] ),
            ) ),
        );
    }

    /**
     * Rank Math: pt_<pt>_sitemap / tax_<tax>_sitemap hold 'on' or 'off'. The master
     * switch is the presence of 'sitemap' in the rank_math_modules list.
     *
     * @return array
     */
    private function read_sitemap_rankmath() {
        $settings = get_option( 'rank-math-options-sitemap', array() );
        $settings = is_array( $settings ) ? $settings : array();
        $modules  = get_option( 'rank_math_modules', array() );

        $post_types = array();
        foreach ( self::public_post_types() as $slug ) {
            $key = 'pt_' . $slug . '_sitemap';
            if ( isset( $settings[ $key ] ) ) {
                $post_types[ $slug ] = ( 'on' === $settings[ $key ] || true === $settings[ $key ] || '1' === (string) $settings[ $key ] );
            }
        }

        $taxonomies = array();
        foreach ( self::public_taxonomies() as $slug ) {
            $key = 'tax_' . $slug . '_sitemap';
            if ( isset( $settings[ $key ] ) ) {
                $taxonomies[ $slug ] = ( 'on' === $settings[ $key ] || true === $settings[ $key ] || '1' === (string) $settings[ $key ] );
            }
        }

        return array(
            'enabled'     => in_array( 'sitemap', (array) $modules, true ),
            'post_types'  => $post_types,
            'taxonomies'  => $taxonomies,
            'notes'       => array(),
            'unmappable'  => $this->collect_unmappable( array(
                'attachment'   => isset( $settings['pt_attachment_sitemap'] ) && 'off' === $settings['pt_attachment_sitemap'],
                'author'       => empty( $settings['authors_sitemap'] ) ? false : true,
                'exclusions'   => ! empty( $settings['exclude_posts'] ) || ! empty( $settings['exclude_terms'] ),
                'images'       => isset( $settings['include_images'] ) && 'off' === $settings['include_images'],
            ) ),
        );
    }

    /**
     * AIOSEO: an 'all' flag plus an 'included' list. all === true means every public
     * type is in; otherwise only what the list names. Independently confirmed by Rank
     * Math's own AIOSEO importer, which reads it the same way.
     *
     * @return array
     */
    private function read_sitemap_aioseo() {
        $decoded = $this->aioseo_options();
        $general = isset( $decoded['sitemap']['general'] ) && is_array( $decoded['sitemap']['general'] )
            ? $decoded['sitemap']['general']
            : array();

        $pt_all      = ! isset( $general['postTypes']['all'] ) || ! empty( $general['postTypes']['all'] );
        $pt_included = isset( $general['postTypes']['included'] ) ? (array) $general['postTypes']['included'] : array();
        $tax_all      = ! isset( $general['taxonomies']['all'] ) || ! empty( $general['taxonomies']['all'] );
        $tax_included = isset( $general['taxonomies']['included'] ) ? (array) $general['taxonomies']['included'] : array();

        $post_types = array();
        foreach ( self::public_post_types() as $slug ) {
            $post_types[ $slug ] = $pt_all ? true : in_array( $slug, $pt_included, true );
        }

        $taxonomies = array();
        foreach ( self::public_taxonomies() as $slug ) {
            $taxonomies[ $slug ] = $tax_all ? true : in_array( $slug, $tax_included, true );
        }

        return array(
            'enabled'     => ! isset( $general['enable'] ) || ! empty( $general['enable'] ),
            'post_types'  => $post_types,
            'taxonomies'  => $taxonomies,
            'notes'       => array(),
            'unmappable'  => $this->collect_unmappable( array(
                'attachment' => ! $pt_all && ! in_array( 'attachment', $pt_included, true ),
                'author'     => ! empty( $general['author'] ),
                'date'       => ! empty( $general['date'] ),
                'exclusions' => ! empty( $general['advancedSettings']['excludePosts'] ) || ! empty( $general['advancedSettings']['excludeTerms'] ),
                'images'     => ! empty( $general['advancedSettings']['excludeImages'] ),
            ) ),
        );
    }

    /**
     * Which source settings exist but have no counterpart in TamRank.
     *
     * Reported, not migrated. Attachments are structurally excluded from TamRank's
     * sitemap; there is no author or date archive sitemap; per-post and per-term
     * exclusions have no equivalent (our _tam_rank_noindex means noindex, not "hide
     * from sitemap"); and the images toggle exists in the option but is read nowhere.
     * Silently dropping them would be the worst option, so the report names them.
     *
     * @param array $flags key => bool, true when the source actually has that setting on.
     * @return string[] Keys that apply.
     */
    private function collect_unmappable( array $flags ) {
        return array_values( array_keys( array_filter( $flags ) ) );
    }

    /**
     * Decode the AIOSEO options blob.
     *
     * Stored as a JSON string with HTML-entity encoded values, not as a PHP array.
     *
     * @return array
     */
    private function aioseo_options() {
        $raw = get_option( 'aioseo_options' );
        if ( is_array( $raw ) ) {
            return $raw;
        }
        if ( ! is_string( $raw ) || '' === $raw ) {
            return array();
        }

        $decoded = json_decode( html_entity_decode( $raw, ENT_QUOTES, 'UTF-8' ), true );
        if ( ! is_array( $decoded ) ) {
            $decoded = json_decode( $raw, true );
        }

        return is_array( $decoded ) ? $decoded : array();
    }

    /* ---------------------------------------------------------------------
     * site identity (organization/person, name, logo, profiles, share image)
     * ------------------------------------------------------------------ */

    /**
     * Read the source plugin's site identity.
     *
     * Four honesty rules run through every reader here, because all three
     * sources seed defaults that say nothing about the site:
     *
     *  1. Only what is really stored counts. Yoast defaults company_or_person to
     *     'company', Rank Math seeds knowledgegraph_name with the site name at
     *     install time, AIOSEO stores the literal template tag '#site_title'.
     *     None of those are things the user told the plugin, so none of them are
     *     migrated.
     *  2. Twitter/X handles become URLs. Yoast twitter_site and Rank Math
     *     twitter_author_names hold a username, while our social_profiles only
     *     accepts URLs (filter_profiles()).
     *  3. Template tags do not resolve into a name. '#site_title' is a
     *     placeholder, not an answer.
     *  4. A person set through a WP user is followed up. Yoast
     *     (company_or_person_user_id) and AIOSEO (schema.person) point at a user
     *     ID instead of filling the name field.
     *
     * @return array {
     *     @type string $entity          'organization'|'person'|'' when the source stores nothing explicit.
     *     @type string $name            Organization or person name, '' when the source has none.
     *     @type string $logo            Logo URL, '' when the source has none.
     *     @type int    $logo_id         Attachment ID behind the logo, 0 when unknown.
     *     @type array  $profiles        Social profile URLs.
     *     @type string $social_image     Fallback share image URL.
     *     @type int    $social_image_id  Attachment ID behind it, 0 when unknown.
     *     @type array  $notes           Machine-readable note keys.
     * }
     */
    public function read_site_identity() {
        switch ( $this->plugin ) {
            case 'yoast':
                return $this->read_identity_yoast();
            case 'rankmath':
                return $this->read_identity_rankmath();
            case 'aioseo':
                return $this->read_identity_aioseo();
        }

        return self::empty_identity();
    }

    /**
     * The shape read_site_identity() returns, with nothing in it.
     *
     * @return array
     */
    private static function empty_identity() {
        return array(
            'entity'          => '',
            'name'            => '',
            'logo'            => '',
            'logo_id'         => 0,
            'profiles'        => array(),
            'social_image'    => '',
            'social_image_id' => 0,
            'notes'           => array(),
        );
    }

    /**
     * Yoast: identity lives in wpseo_titles, the social side in wpseo_social.
     *
     * company_or_person defaults to 'company', so only the explicit 'person'
     * value is treated as an answer; everything else falls back to reading the
     * company fields, which are empty unless the user filled them.
     *
     * @return array
     */
    private function read_identity_yoast() {
        $titles = get_option( 'wpseo_titles', array() );
        $social = get_option( 'wpseo_social', array() );
        $titles = is_array( $titles ) ? $titles : array();
        $social = is_array( $social ) ? $social : array();

        $identity = self::empty_identity();
        $is_person = isset( $titles['company_or_person'] ) && 'person' === $titles['company_or_person'];
        $identity['entity'] = $is_person ? 'person' : ( isset( $titles['company_or_person'] ) ? 'organization' : '' );

        if ( $is_person ) {
            $identity['name']    = isset( $titles['person_name'] ) ? trim( (string) $titles['person_name'] ) : '';
            $identity['logo']    = isset( $titles['person_logo'] ) ? trim( (string) $titles['person_logo'] ) : '';
            $identity['logo_id'] = isset( $titles['person_logo_id'] ) ? (int) $titles['person_logo_id'] : 0;

            if ( '' === $identity['name'] && ! empty( $titles['company_or_person_user_id'] ) ) {
                $identity['name'] = self::user_display_name( $titles['company_or_person_user_id'] );
            }
        } else {
            $identity['name']    = isset( $titles['company_name'] ) ? trim( (string) $titles['company_name'] ) : '';
            $identity['logo']    = isset( $titles['company_logo'] ) ? trim( (string) $titles['company_logo'] ) : '';
            $identity['logo_id'] = isset( $titles['company_logo_id'] ) ? (int) $titles['company_logo_id'] : 0;
        }

        $profiles = array();
        if ( ! empty( $social['facebook_site'] ) ) {
            $profiles[] = (string) $social['facebook_site'];
        }
        if ( ! empty( $social['twitter_site'] ) ) {
            $profiles[] = self::handle_to_url( $social['twitter_site'] );
        }
        foreach ( (array) ( isset( $social['other_social_urls'] ) ? $social['other_social_urls'] : array() ) as $url ) {
            $profiles[] = (string) $url;
        }
        $identity['profiles'] = self::clean_profiles( $profiles );

        $identity['social_image']    = isset( $social['og_default_image'] ) ? trim( (string) $social['og_default_image'] ) : '';
        $identity['social_image_id'] = isset( $social['og_default_image_id'] ) ? (int) $social['og_default_image_id'] : 0;

        return $identity;
    }

    /**
     * Rank Math: everything sits in one option, rank-math-options-titles.
     *
     * The name needs the extra guard: Rank Math seeds knowledgegraph_name with
     * get_bloginfo('name') during install (includes/class-installer.php), so a
     * name equal to the site name is the seed, not an answer. TamRank's own
     * default for organization_name is that same site name, so skipping it
     * loses nothing.
     *
     * @return array
     */
    private function read_identity_rankmath() {
        $titles = get_option( 'rank-math-options-titles', array() );
        $titles = is_array( $titles ) ? $titles : array();

        $identity  = self::empty_identity();
        $is_person = isset( $titles['knowledgegraph_type'] ) && 'person' === $titles['knowledgegraph_type'];
        $identity['entity'] = $is_person ? 'person' : ( isset( $titles['knowledgegraph_type'] ) ? 'organization' : '' );

        $name = isset( $titles['knowledgegraph_name'] ) ? trim( (string) $titles['knowledgegraph_name'] ) : '';
        if ( '' !== $name && $name === trim( (string) get_bloginfo( 'name' ) ) ) {
            $identity['notes'][] = 'rankmath_name_is_install_default';
            $name = '';
        }
        $identity['name'] = $name;

        $identity['logo']    = isset( $titles['knowledgegraph_logo'] ) ? trim( (string) $titles['knowledgegraph_logo'] ) : '';
        $identity['logo_id'] = isset( $titles['knowledgegraph_logo_id'] ) ? (int) $titles['knowledgegraph_logo_id'] : 0;

        $profiles = array();
        if ( ! empty( $titles['social_url_facebook'] ) ) {
            $profiles[] = (string) $titles['social_url_facebook'];
        }
        if ( ! empty( $titles['twitter_author_names'] ) ) {
            $profiles[] = self::handle_to_url( $titles['twitter_author_names'] );
        }
        foreach ( self::split_lines( isset( $titles['social_additional_profiles'] ) ? $titles['social_additional_profiles'] : '' ) as $url ) {
            $profiles[] = $url;
        }
        $identity['profiles'] = self::clean_profiles( $profiles );

        $identity['social_image']    = isset( $titles['open_graph_image'] ) ? trim( (string) $titles['open_graph_image'] ) : '';
        $identity['social_image_id'] = isset( $titles['open_graph_image_id'] ) ? (int) $titles['open_graph_image_id'] : 0;

        return $identity;
    }

    /**
     * AIOSEO: one JSON blob, and the defaults in it are template tags.
     *
     * organizationName defaults to the literal '#site_title'; anything still
     * carrying a '#tag' is a placeholder and not a name. The person side points
     * at a user ID unless it is set to 'manual', in which case personName holds
     * the answer.
     *
     * @return array
     */
    private function read_identity_aioseo() {
        $decoded = $this->aioseo_options();

        $schema = isset( $decoded['searchAppearance']['global']['schema'] ) && is_array( $decoded['searchAppearance']['global']['schema'] )
            ? $decoded['searchAppearance']['global']['schema']
            : array();
        $social = isset( $decoded['social'] ) && is_array( $decoded['social'] ) ? $decoded['social'] : array();

        $identity  = self::empty_identity();
        $represents = isset( $schema['siteRepresents'] ) ? (string) $schema['siteRepresents'] : '';
        $is_person  = 'person' === $represents;
        $identity['entity'] = $is_person ? 'person' : ( '' !== $represents ? 'organization' : '' );

        if ( $is_person ) {
            $person = isset( $schema['person'] ) ? (string) $schema['person'] : '';
            if ( 'manual' === $person ) {
                $identity['name'] = self::strip_template_tags( isset( $schema['personName'] ) ? $schema['personName'] : '' );
            } elseif ( '' !== $person ) {
                $identity['name'] = self::user_display_name( $person );
            }
            $identity['logo'] = self::strip_template_tags( isset( $schema['personLogo'] ) ? $schema['personLogo'] : '' );
        } else {
            $identity['name'] = self::strip_template_tags( isset( $schema['organizationName'] ) ? $schema['organizationName'] : '' );
            $identity['logo'] = self::strip_template_tags( isset( $schema['organizationLogo'] ) ? $schema['organizationLogo'] : '' );
        }

        $urls     = isset( $social['profiles']['urls'] ) && is_array( $social['profiles']['urls'] ) ? $social['profiles']['urls'] : array();
        $profiles = array();
        foreach ( $urls as $url ) {
            if ( is_string( $url ) && '' !== trim( $url ) ) {
                $profiles[] = trim( $url );
            }
        }
        foreach ( self::split_lines( isset( $social['profiles']['additionalUrls'] ) ? $social['profiles']['additionalUrls'] : '' ) as $url ) {
            $profiles[] = $url;
        }
        $identity['profiles'] = self::clean_profiles( $profiles );

        // Only the Facebook default image is read. Twitter has a field of its
        // own, but useOgData defaults to true, so on a default install the
        // Twitter card already shows this same image.
        $identity['social_image'] = isset( $social['facebook']['general']['defaultImagePosts'] )
            ? self::strip_template_tags( $social['facebook']['general']['defaultImagePosts'] )
            : '';

        return $identity;
    }

    /**
     * Display name of a WP user, '' when the user is gone.
     *
     * @param int|string $user_id
     * @return string
     */
    private static function user_display_name( $user_id ) {
        $user = get_userdata( (int) $user_id );

        return ( $user && ! empty( $user->display_name ) ) ? (string) $user->display_name : '';
    }

    /**
     * A Twitter/X username as a URL.
     *
     * Yoast and Rank Math store a handle, our social_profiles only accepts
     * URLs. A value that is already a URL is passed through unchanged.
     *
     * @param string $handle
     * @return string
     */
    private static function handle_to_url( $handle ) {
        $handle = trim( (string) $handle );

        if ( '' === $handle ) {
            return '';
        }
        if ( 0 === stripos( $handle, 'http://' ) || 0 === stripos( $handle, 'https://' ) ) {
            return $handle;
        }

        $handle = ltrim( $handle, '@' );
        // Rank Math's field is plural and accepts a comma separated list; the
        // first name is the site's own account.
        if ( false !== strpos( $handle, ',' ) ) {
            $parts  = array_filter( array_map( 'trim', explode( ',', $handle ) ) );
            $handle = $parts ? ltrim( reset( $parts ), '@' ) : '';
        }
        if ( '' === $handle ) {
            return '';
        }

        return 'https://x.com/' . rawurlencode( $handle );
    }

    /**
     * Split a textarea value into trimmed, non-empty lines.
     *
     * @param string $value
     * @return string[]
     */
    private static function split_lines( $value ) {
        $value = trim( (string) $value );
        if ( '' === $value ) {
            return array();
        }

        $lines = preg_split( '/[\r\n]+/', $value );

        return array_values( array_filter( array_map( 'trim', (array) $lines ) ) );
    }

    /**
     * Drop empties, drop anything that is not an http(s) URL, and de-duplicate.
     *
     * Same shape our own filter_profiles() expects, applied here so the wizard
     * card counts what would really be written.
     *
     * @param array $profiles
     * @return string[]
     */
    private static function clean_profiles( array $profiles ) {
        $clean = array();

        foreach ( $profiles as $url ) {
            $url = trim( (string) $url );
            if ( '' === $url ) {
                continue;
            }
            if ( 0 !== stripos( $url, 'http://' ) && 0 !== stripos( $url, 'https://' ) ) {
                continue;
            }
            $clean[] = $url;
        }

        return array_values( array_unique( $clean ) );
    }

    /**
     * A value that still contains an AIOSEO template tag is a placeholder.
     *
     * '#site_title' is what AIOSEO ships as the default organization name; it
     * says nothing about the site, so it is not migrated.
     *
     * @param string $value
     * @return string
     */
    private static function strip_template_tags( $value ) {
        $value = trim( (string) $value );

        if ( '' === $value || preg_match( '/#[a-z_]+/i', $value ) ) {
            return '';
        }

        return $value;
    }

    /* ---------------------------------------------------------------------
     * shared type lists
     * ------------------------------------------------------------------ */

    /**
     * Public post types TamRank writes sitemap booleans for.
     *
     * Deliberately the same selection rules as the settings sanitizer
     * (admin/settings-page.php), so the written set is exactly the set the UI shows and
     * the sanitizer accepts.
     *
     * @return string[]
     */
    public static function public_post_types() {
        $slugs = array();
        foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $slug => $pt ) {
            if ( 'attachment' === $slug ) {
                continue;
            }
            if ( false === $pt->publicly_queryable && 'page' !== $slug ) {
                continue;
            }
            $slugs[] = $slug;
        }

        return $slugs;
    }

    /**
     * Public taxonomies TamRank writes sitemap booleans for.
     *
     * @return string[]
     */
    public static function public_taxonomies() {
        $internal = array( 'post_format', 'nav_menu', 'link_category', 'wp_theme', 'wp_template_part_area' );
        $slugs    = array();

        foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $slug => $tax ) {
            if ( in_array( $slug, $internal, true ) ) {
                continue;
            }
            if ( false === $tax->publicly_queryable ) {
                continue;
            }
            $slugs[] = $slug;
        }

        return $slugs;
    }

}
