<?php
/**
 * TamRank settings migrator
 *
 * Migrates the site-wide SEO settings the per-post migrators do not cover:
 * the robots.txt body, the XML sitemap configuration and the site identity
 * (brand name, logo, social profiles and the fallback share image).
 *
 * Both are single, site-wide values, so this runs once per migration (first batch)
 * rather than per post. The option backup is written by the manager before this class
 * touches anything.
 */

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

class TamRank_Settings_Migrator {

    /** @var string Category key for the robots.txt import. */
    const CATEGORY_ROBOTS = 'robots_txt';

    /** @var string Category key for the sitemap configuration import. */
    const CATEGORY_SITEMAP = 'sitemap_config';

    /** @var string Category key for the site identity import. */
    const CATEGORY_IDENTITY = 'site_identity';

    /** @var string Option the site identity is written into. */
    const IDENTITY_OPTION = 'tamrank_schema_global_settings';

    /** @var string Option holding the human-readable report of the last run. */
    const REPORT_OPTION = 'tamrank_migration_settings_report';

    /** @var string Source plugin slug. */
    private $plugin;

    /** @var TamRank_Settings_Source */
    private $source;

    /**
     * @param string $plugin Source plugin slug: yoast|rankmath|aioseo.
     */
    public function __construct( $plugin ) {
        $this->plugin = (string) $plugin;

        if ( ! class_exists( 'TamRank_Settings_Source' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-settings-source.php';
        }
        if ( ! class_exists( 'TamRank_Robots_Capture' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-robots-capture.php';
        }
        if ( ! class_exists( 'TamRank_Physical_Robots' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-physical-robots.php';
        }

        if ( ! class_exists( 'TAMRANK_Schema_Settings' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/schema/class-schema-settings.php';
        }

        $this->source = new TamRank_Settings_Source( $this->plugin );
    }

    /* ---------------------------------------------------------------------
     * Reading / preview
     * ------------------------------------------------------------------ */

    /**
     * The robots.txt body TamRank would import, with provenance.
     *
     * Primary source is the live loopback capture; the option reading is the fallback
     * and is also the primary source when the capture cannot be representative
     * (non-public site, Rank Math with an unfinished setup - both measured).
     *
     * @return array {
     *     @type string $content    Body to import ('' when there is nothing).
     *     @type string $origin     'physical_file' | 'loopback' | 'options'
     *     @type string $fail_reason Why the capture was not used, '' when it was.
     *     @type array  $notes      Machine-readable note keys.
     *     @type string $search_appearance AIOSEO search-appearance block, for labelling.
     * }
     */
    public function read_robots() {
        $from_options = $this->source->read_robots_from_options();
        $notes        = isset( $from_options['notes'] ) ? (array) $from_options['notes'] : array();

        // A physical file beats everything: it is literally what the web server hands
        // out, and it is also what the loopback capture would return.
        $physical = TamRank_Physical_Robots::read();
        if ( '' !== trim( $physical ) ) {
            $notes[] = 'physical_file_detected';
            return array(
                'content'           => trim( $physical ),
                'origin'            => 'physical_file',
                'fail_reason'       => '',
                'notes'             => $notes,
                'search_appearance' => isset( $from_options['search_appearance'] ) ? $from_options['search_appearance'] : '',
            );
        }

        if ( TamRank_Robots_Capture::is_representative( $this->plugin ) ) {
            $capture = TamRank_Robots_Capture::fetch();
            if ( $capture['ok'] ) {
                return array(
                    'content'           => trim( $capture['body'] ),
                    'origin'            => 'loopback',
                    'fail_reason'       => '',
                    'notes'             => $notes,
                    'search_appearance' => isset( $from_options['search_appearance'] ) ? $from_options['search_appearance'] : '',
                );
            }
            $notes[]     = 'capture_failed';
            $fail_reason = $capture['message'];
        } else {
            $notes[]     = get_option( 'blog_public' ) ? 'capture_not_representative_rankmath' : 'capture_not_representative_nonpublic';
            $fail_reason = '';
        }

        return array(
            'content'           => trim( (string) $from_options['content'] ),
            'origin'            => 'options',
            'fail_reason'       => $fail_reason,
            'notes'             => $notes,
            'search_appearance' => isset( $from_options['search_appearance'] ) ? $from_options['search_appearance'] : '',
        );
    }

    /**
     * The exact value plugin activation seeds into tam_rank_robots_txt.
     *
     * Recomputed with the same expression as the activation routine, so "the user never
     * touched this" can be recognised instead of guessed.
     *
     * @return string
     */
    public static function seeded_robots_default() {
        return "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n\nSitemap: " . home_url( '/sitemap.xml' );
    }

    /**
     * Whether the stored robots.txt is safe to overwrite without asking.
     *
     * Safe means: empty, or byte-equal to the seeded default after normalising line
     * endings and trimming. Anything else is something the user typed.
     *
     * @return bool
     */
    public static function stored_robots_is_untouched() {
        $stored = (string) get_option( 'tam_rank_robots_txt', '' );
        $stored = trim( str_replace( array( "\r\n", "\r" ), "\n", $stored ) );

        if ( '' === $stored ) {
            return true;
        }

        return $stored === trim( self::seeded_robots_default() );
    }

    /**
     * Everything the wizard needs to show before writing anything.
     *
     * @return array
     */
    public function preview() {
        $robots  = $this->read_robots();
        $sitemap = $this->source->read_sitemap_config();
        $target  = $this->build_robots_target( $robots['content'], (bool) $sitemap['enabled'] );

        return array(
            'robots'           => array(
                'origin'            => $robots['origin'],
                'fail_reason'       => $robots['fail_reason'],
                'notes'             => array_values( array_unique( $robots['notes'] ) ),
                'source_content'    => $robots['content'],
                'target_content'    => $target['content'],
                'sitemap_rewritten' => $target['rewritten'],
                'sitemap_removed'   => $target['removed'],
                'search_appearance' => $robots['search_appearance'],
                'current_content'   => (string) get_option( 'tam_rank_robots_txt', '' ),
                'is_untouched'      => self::stored_robots_is_untouched(),
                'physical_file'     => TamRank_Physical_Robots::find(),
            ),
            'sitemap'          => array(
                'enabled'    => (bool) $sitemap['enabled'],
                'post_types' => $sitemap['post_types'],
                'taxonomies' => $sitemap['taxonomies'],
                'excluded'   => $this->count_excluded( $sitemap ),
                'notes'      => array_values( array_unique( (array) $sitemap['notes'] ) ),
            ),
        );
    }

    /**
     * How many types the source excludes from its sitemap - the number the wizard card
     * shows, so the count means something concrete.
     *
     * @param array $sitemap Source sitemap config.
     * @return int
     */
    private function count_excluded( array $sitemap ) {
        $excluded = 0;
        foreach ( array( 'post_types', 'taxonomies' ) as $group ) {
            foreach ( (array) $sitemap[ $group ] as $value ) {
                if ( ! $value ) {
                    $excluded++;
                }
            }
        }

        return $excluded;
    }

    /**
     * Counts for the two wizard cards.
     *
     * Deliberately reads options only, never the loopback capture: this runs on every
     * scan and a card count is not worth an HTTP request with a ten second timeout. The
     * capture happens once, at migration time.
     *
     * @return array { @type int robots_txt, @type int sitemap_config }
     */
    public function category_counts() {
        $from_options = $this->source->read_robots_from_options();
        $has_robots   = '' !== trim( (string) $from_options['content'] ) || TamRank_Physical_Robots::exists();

        $sitemap = $this->source->read_sitemap_config();
        $changes = $this->count_excluded( $sitemap ) + ( $sitemap['enabled'] ? 0 : 1 );

        return array(
            self::CATEGORY_ROBOTS   => $has_robots ? 1 : 0,
            self::CATEGORY_SITEMAP  => $changes,
            self::CATEGORY_IDENTITY => $this->count_identity_fields(),
        );
    }

    /**
     * How many identity fields the source really carries.
     *
     * Counts fields, not the single import: the card says what was found in the
     * old plugin, the same way the sitemap card counts exclusions. The batch API
     * reports one item for the whole category, which is the documented
     * scanner-versus-batch unit difference, not a bug.
     *
     * The person case is left out of the count, so the card never offers an
     * organisation-only field on a site that presents itself as a person. A
     * field that TamRank already has filled in is still counted: it was found
     * in the old plugin, and the run reports it as kept.
     *
     * @return int
     */
    private function count_identity_fields() {
        $identity = $this->source->read_site_identity();
        $count    = 0;

        if ( 'person' !== $identity['entity'] ) {
            if ( '' !== $identity['name'] ) {
                $count++;
            }
            if ( '' !== $identity['logo'] ) {
                $count++;
            }
        }

        $count += count( $identity['profiles'] );

        if ( '' !== $identity['social_image'] ) {
            $count++;
        }

        return $count;
    }

    /* ---------------------------------------------------------------------
     * Writing
     * ------------------------------------------------------------------ */

    /**
     * Run the settings part of a migration.
     *
     * @param array $categories       Selected category keys.
     * @param array $options          Wizard options; 'robots_conflict' => 'keep'|'replace'.
     * @return array { @type int $imported, @type int $changed, @type array $errors, @type array $notes }
     */
    public function run( array $categories, array $options = array() ) {
        $wants_robots   = in_array( self::CATEGORY_ROBOTS, $categories, true );
        $wants_sitemap  = in_array( self::CATEGORY_SITEMAP, $categories, true );
        $wants_identity = in_array( self::CATEGORY_IDENTITY, $categories, true );

        if ( ! $wants_robots && ! $wants_sitemap && ! $wants_identity ) {
            return array( 'imported' => 0, 'changed' => 0, 'errors' => array(), 'notes' => array() );
        }

        $report   = array(
            'plugin'     => $this->plugin,
            'created_at' => current_time( 'mysql' ),
            'robots'     => array(),
            'sitemap'    => array(),
            'identity'   => array(),
        );
        $imported = 0;
        $changed  = 0;
        $errors   = array();

        $source_sitemap = $this->source->read_sitemap_config();

        // 'changed' is counted here and then dropped from the fragment: it is a
        // fact about this run, for this response, and the stored report keeps
        // exactly the shape it had - changing a persisted structure that nothing
        // reads is a silent schema break for no gain.
        $tally = function ( array $result ) use ( &$imported, &$changed ) {
            if ( ! empty( $result['written'] ) ) {
                $imported++;
            }
            if ( ! empty( $result['changed'] ) ) {
                $changed++;
            }
            unset( $result['changed'] );
            return $result;
        };

        if ( $wants_robots ) {
            $result = $this->write_robots( $options, (bool) $source_sitemap['enabled'] );
            if ( ! empty( $result['error'] ) ) {
                $errors[] = $result['error'];
            }
            $report['robots'] = $tally( $result );
        }

        if ( $wants_sitemap ) {
            $report['sitemap'] = $tally( $this->write_sitemap_config( $source_sitemap ) );
        }

        if ( $wants_identity ) {
            $report['identity'] = $tally( $this->write_site_identity() );
        }

        update_option( self::REPORT_OPTION, $report, false );

        return array(
            'imported' => $imported,
            'changed'  => $changed,
            'errors'   => $errors,
            'notes'    => self::report_lines( $report ),
        );
    }

    /**
     * Turn a report into plain-language lines for the wizard summary.
     *
     * Says what was taken over, what was skipped and why, what was replaced, what could
     * not be mapped, which rules came from AIOSEO's Search Appearance, and whether the
     * sitemap was switched off on the source.
     *
     * @param array $report Report as stored by run().
     * @return string[]
     */
    public static function report_lines( array $report ) {
        $lines = array();

        if ( ! empty( $report['robots'] ) ) {
            $robots = $report['robots'];
            $notes  = isset( $robots['notes'] ) ? (array) $robots['notes'] : array();

            if ( ! empty( $robots['written'] ) ) {
                $lines[] = tamrank__( 'report_robots_written', 'robots.txt imported.' );
            } elseif ( 'kept_user_value' === ( $robots['skipped_reason'] ?? '' ) ) {
                $lines[] = tamrank__( 'report_robots_kept', 'robots.txt skipped: your own version was kept.' );
            } elseif ( 'nothing_to_import' === ( $robots['skipped_reason'] ?? '' ) ) {
                $lines[] = tamrank__( 'report_robots_nothing', 'robots.txt skipped: there was nothing to import.' );
            }

            if ( ! empty( $robots['written'] ) && 'options' === ( $robots['origin'] ?? '' ) ) {
                if ( in_array( 'capture_not_representative_nonpublic', $notes, true ) ) {
                    $lines[] = tamrank__( 'robots_origin_nonpublic', 'Your site is set to discourage search engines, which makes the live robots.txt unrepresentative. This version comes from your old plugin\'s settings.' );
                } elseif ( in_array( 'capture_not_representative_rankmath', $notes, true ) ) {
                    $lines[] = tamrank__( 'robots_origin_rankmath', 'Rank Math has not finished its setup, so it is not serving its own robots.txt yet. This version comes straight from its settings.' );
                } else {
                    $lines[] = sprintf(
                        tamrank__( 'robots_origin_options', 'We could not fetch the live robots.txt (%s), so this version is reconstructed from your old plugin\'s settings. Check it before you import.' ),
                        (string) ( $robots['fail_reason'] ?? '' )
                    );
                }
            }

            if ( ! empty( $robots['written'] ) && ! empty( $robots['sitemap_rewritten'] ) ) {
                $lines[] = tamrank__( 'robots_sitemap_rewritten', 'The Sitemap: line now points at TamRank\'s sitemap. A Sitemap: line pointing at another website is left alone.' );
            }

            if ( in_array( 'aioseo_search_appearance_rules', $notes, true ) ) {
                $lines[] = tamrank__( 'robots_search_appearance', 'These rules come from All in One SEO\'s Search Appearance / crawl cleanup, not from its robots.txt editor:' );
            }

            if ( ! empty( $robots['physical_file'] ) ) {
                $lines[] = 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.' ),
                    (string) $robots['physical_file']
                );
            }
        }

        if ( ! empty( $report['sitemap']['written'] ) ) {
            $sitemap = $report['sitemap'];
            $lines[] = sprintf(
                tamrank__( 'report_sitemap_written', 'Sitemap settings imported for %d content types.' ),
                (int) $sitemap['types_written']
            );
            if ( ! empty( $sitemap['excluded'] ) ) {
                $lines[] = sprintf(
                    tamrank__( 'report_sitemap_excluded', 'Kept out of the sitemap: %s' ),
                    implode( ', ', (array) $sitemap['excluded'] )
                );
            }
            if ( empty( $sitemap['sitemap_enabled'] ) ) {
                $lines[] = tamrank__( 'report_sitemap_disabled', 'Your sitemap was switched off in your old plugin, so it is switched off in TamRank too.' );
            }
            if ( ! empty( $sitemap['unmappable'] ) ) {
                $labels = array(
                    'attachment' => tamrank__( 'unmappable_attachment', 'attachments (TamRank always keeps these out of the sitemap)' ),
                    'author'     => tamrank__( 'unmappable_author', 'the author archive (TamRank has no author sitemap)' ),
                    'date'       => tamrank__( 'unmappable_date', 'the date archive (TamRank has no date sitemap)' ),
                    'exclusions' => tamrank__( 'unmappable_exclusions', 'exclusions for individual posts or terms (TamRank has no equivalent for these)' ),
                    'images'     => tamrank__( 'unmappable_images', 'the images setting (TamRank always includes images)' ),
                );
                $named = array();
                foreach ( (array) $sitemap['unmappable'] as $key ) {
                    if ( isset( $labels[ $key ] ) ) {
                        $named[] = $labels[ $key ];
                    }
                }
                if ( $named ) {
                    $lines[] = sprintf(
                        tamrank__( 'report_sitemap_unmappable', 'Not taken over, because TamRank has no setting for it: %s' ),
                        implode( '; ', $named )
                    );
                }
            }
        }

        if ( ! empty( $report['identity'] ) ) {
            $lines = array_merge( $lines, self::identity_report_lines( (array) $report['identity'] ) );
        }

        return $lines;
    }

    /**
     * Plain-language lines for the site identity import.
     *
     * Split out of report_lines() because this category has four outcomes that
     * can occur together: something written, something deliberately kept,
     * the person case skipped, and a source value that turned out to be an
     * install default.
     *
     * @param array $identity Report fragment as stored by write_site_identity().
     * @return string[]
     */
    private static function identity_report_lines( array $identity ) {
        $lines  = array();
        $labels = array(
            'name'         => tamrank__( 'identity_field_name', 'the organization name' ),
            'logo'         => tamrank__( 'identity_field_logo', 'the logo' ),
            'profiles'     => tamrank__( 'identity_field_profiles', 'the social profiles' ),
            'social_image' => tamrank__( 'identity_field_social_image', 'the fallback share image' ),
        );

        $named = function ( $keys ) use ( $labels ) {
            $out = array();
            foreach ( (array) $keys as $key ) {
                if ( isset( $labels[ $key ] ) ) {
                    $out[] = $labels[ $key ];
                }
            }
            return $out;
        };

        $written = $named( isset( $identity['fields'] ) ? $identity['fields'] : array() );
        if ( $written ) {
            $lines[] = sprintf(
                tamrank__( 'report_identity_written', 'Brand details taken over: %s. You will find them under Schema, in the global settings.' ),
                implode( ', ', $written )
            );
        }

        $kept = $named( isset( $identity['kept'] ) ? $identity['kept'] : array() );
        if ( $kept ) {
            $lines[] = sprintf(
                tamrank__( 'report_identity_kept', 'Left as it was, because TamRank already had something there: %s.' ),
                implode( ', ', $kept )
            );
        }

        if ( ! empty( $identity['person_skipped'] ) ) {
            $lines[] = tamrank__( 'report_identity_person', 'Your old plugin says this site stands for a person. TamRank only knows an organization, so the name and the picture behind it were not taken over; your social profiles and fallback image were. You can fill in the rest yourself under Schema.' );
        }

        if ( in_array( 'rankmath_name_is_install_default', (array) ( isset( $identity['notes'] ) ? $identity['notes'] : array() ), true ) ) {
            $lines[] = tamrank__( 'report_identity_rm_default_name', 'Rank Math still held the site name it fills in by itself on install, so there was no brand name of your own to take over.' );
        }

        if ( empty( $written ) && empty( $kept ) && empty( $identity['person_skipped'] ) ) {
            $lines[] = tamrank__( 'report_identity_nothing', 'Brand details skipped: there was nothing to take over.' );
        }

        return $lines;
    }

    /**
     * Import the robots.txt body.
     *
     * @param array $options        Wizard options.
     * @param bool  $source_enabled Source sitemap master switch.
     * @return array Report fragment.
     */
    private function write_robots( array $options, $source_enabled ) {
        $robots = $this->read_robots();
        $target = $this->build_robots_target( $robots['content'], $source_enabled );

        $report = array(
            'origin'            => $robots['origin'],
            'fail_reason'       => $robots['fail_reason'],
            'notes'             => array_values( array_unique( $robots['notes'] ) ),
            'sitemap_rewritten' => $target['rewritten'],
            'sitemap_removed'   => $target['removed'],
            'physical_file'     => TamRank_Physical_Robots::find(),
            'written'           => false,
            'skipped_reason'    => '',
        );

        if ( '' === trim( $target['content'] ) ) {
            $report['skipped_reason'] = 'nothing_to_import';
            return $report;
        }

        $choice = isset( $options['robots_conflict'] ) ? (string) $options['robots_conflict'] : '';

        if ( ! self::stored_robots_is_untouched() && 'replace' !== $choice ) {
            // The user edited this field themselves. Keeping it is the default and is
            // counted as skipped-with-a-reason, exactly like an existing redirect:
            // existing TamRank data is never overwritten.
            $report['skipped_reason'] = 'kept_user_value';
            return $report;
        }

        // update_option() answers "was this different from what was stored?", and
        // that is the only honest answer to "did anything move on this run?" -
        // 'written' stays what it was (the run did write), 'changed' is what the
        // wizard's result card is allowed to call a move.
        $changed = tam_rank_write_option_raw( 'tam_rank_robots_txt', $target['content'] );

        $report['written'] = true;
        $report['changed'] = (bool) $changed;

        return $report;
    }

    /**
     * Turn a captured robots.txt into the body TamRank should store.
     *
     * Sitemap: directives pointing at this host are replaced by exactly one line for our
     * own sitemap - otherwise the imported file keeps advertising /sitemap_index.xml,
     * which 404s the moment the old plugin is switched off. Directives pointing at an
     * external host (a news sitemap from another tool) are left alone.
     *
     * With the sitemap switched off on the source, the own directive is removed and not
     * replaced.
     *
     * @param string $content        Captured/reconstructed body.
     * @param bool   $sitemap_enabled Whether the sitemap is on after migration.
     * @return array { @type string $content, @type bool $rewritten, @type bool $removed }
     */
    private function build_robots_target( $content, $sitemap_enabled ) {
        $content = trim( str_replace( array( "\r\n", "\r" ), "\n", (string) $content ) );
        if ( '' === $content ) {
            return array( 'content' => '', 'rewritten' => false, 'removed' => false );
        }

        $own_host  = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
        $lines     = explode( "\n", $content );
        $kept      = array();
        $found_own = false;

        foreach ( $lines as $line ) {
            if ( preg_match( '/^\s*sitemap\s*:\s*(\S+)/i', $line, $m ) ) {
                $host = strtolower( (string) wp_parse_url( $m[1], PHP_URL_HOST ) );
                if ( '' === $host || $host === $own_host ) {
                    $found_own = true;
                    continue; // Dropped here; re-added once below when the sitemap is on.
                }
            }
            $kept[] = $line;
        }

        $body = rtrim( implode( "\n", $kept ) );

        if ( $sitemap_enabled ) {
            $body .= "\n\nSitemap: " . home_url( '/sitemap.xml' );
        }

        // Removing a directive from the middle of a block can leave a double blank
        // line behind; collapse runs of blank lines to a single one.
        $body = preg_replace( "/\n{3,}/", "\n\n", $body );

        return array(
            'content'   => trim( $body ),
            'rewritten' => $found_own && $sitemap_enabled,
            'removed'   => $found_own && ! $sitemap_enabled,
        );
    }

    /**
     * Import the sitemap configuration.
     *
     * Writes an explicit boolean for EVERY public post type and taxonomy, including the
     * ones that stay on. That is the whole point: TamRank's default is "missing = on",
     * and a deliberately excluded tag archive would otherwise quietly come back. The
     * default itself is left exactly as it is - flipping it would empty the sitemap on
     * every install that never saved this tab.
     *
     * Read-merge-write: every other key already in the option is kept. The flat
     * activation keys are not all dead (posts_priority/pages_priority are read), and
     * dropping keys is a behaviour change this migration has no business making. The
     * write goes through tam_rank_write_option_raw() because the register_setting
     * sanitizer would drop those keys and reshape the rest.
     *
     * @param array $source_config Source sitemap config.
     * @return array Report fragment.
     */
    private function write_sitemap_config( array $source_config ) {
        $current = get_option( 'tam_rank_sitemap_settings', array() );
        $current = is_array( $current ) ? $current : array();

        $existing_ct = isset( $current['content_types'] ) && is_array( $current['content_types'] )
            ? $current['content_types']
            : array();

        $post_types = array();
        foreach ( TamRank_Settings_Source::public_post_types() as $slug ) {
            $post_types[ $slug ] = array_key_exists( $slug, (array) $source_config['post_types'] )
                ? (bool) $source_config['post_types'][ $slug ]
                : true; // Source says nothing about it: keep TamRank's own default.
        }

        $taxonomies = array();
        foreach ( TamRank_Settings_Source::public_taxonomies() as $slug ) {
            $taxonomies[ $slug ] = array_key_exists( $slug, (array) $source_config['taxonomies'] )
                ? (bool) $source_config['taxonomies'][ $slug ]
                : true;
        }

        // Keep explicit values for types that are stored but not registered right now.
        foreach ( array( 'post_types', 'taxonomies' ) as $group ) {
            if ( empty( $existing_ct[ $group ] ) || ! is_array( $existing_ct[ $group ] ) ) {
                continue;
            }
            foreach ( $existing_ct[ $group ] as $slug => $value ) {
                if ( 'post_types' === $group && ! array_key_exists( $slug, $post_types ) ) {
                    $post_types[ $slug ] = (bool) $value;
                } elseif ( 'taxonomies' === $group && ! array_key_exists( $slug, $taxonomies ) ) {
                    $taxonomies[ $slug ] = (bool) $value;
                }
            }
        }

        $new = $current;
        $new['sitemap_enabled'] = (bool) $source_config['enabled'];
        $new['content_types']   = array(
            'post_types' => $post_types,
            'taxonomies' => $taxonomies,
        );

        // A dead key that must never be read again; drop it while we are here.
        unset( $new['auto_generate'] );

        // Unconditional write: this option is rewritten on every run, with the same
        // values on a re-run. 'changed' is what separates the two.
        $changed = tam_rank_write_option_raw( 'tam_rank_sitemap_settings', $new );

        // Without this the router keeps serving the cached pre-migration sitemap until
        // the transient expires, so an excluded tag archive would still show up.
        tam_rank_flush_sitemap_transients();

        $excluded = array();
        foreach ( $post_types as $slug => $on ) {
            if ( ! $on ) {
                $excluded[] = $slug;
            }
        }
        foreach ( $taxonomies as $slug => $on ) {
            if ( ! $on ) {
                $excluded[] = $slug;
            }
        }

        return array(
            'written'         => true,
            'changed'         => (bool) $changed,
            'sitemap_enabled' => (bool) $source_config['enabled'],
            'excluded'        => $excluded,
            'types_written'   => count( $post_types ) + count( $taxonomies ),
            'notes'           => array_values( array_unique( (array) $source_config['notes'] ) ),
            'unmappable'      => isset( $source_config['unmappable'] ) ? (array) $source_config['unmappable'] : array(),
        );
    }

    /**
     * Import the site identity into the schema settings.
     *
     * Writes into the option the Schema tab already owns, so everything this
     * migration takes over stays visible and editable in the one place the user
     * would look for it. Four deliberate limits:
     *
     *  - entity_type is NEVER written. TamRank offers Organization,
     *    LocalBusiness and Custom on the Schema tab and has no Person card, so
     *    writing 'Person' would put a value in the option that the UI cannot
     *    show or edit. Writing 'Organization' would be just as wrong the other
     *    way round: it would flatten a LocalBusiness or Custom choice the user
     *    made in TamRank back to the source's opinion.
     *  - In the person case the name and the logo are skipped as well. A person
     *    name on TamRank's Organization node is a claim about the site that is
     *    simply not true, and the report says so instead of quietly doing it.
     *    The social profiles and the fallback share image do come along: those
     *    are the site's own URLs and its own image either way.
     *  - Existing TamRank data always wins, per field. Same promise as
     *    migrate_post() makes for post meta and write_robots() for robots.txt.
     *  - The write goes through tam_rank_write_option_raw(), because the
     *    register_setting sanitizer for this option rebuilds the array from a
     *    whitelist and would drop every key this migration did not touch.
     *
     * @return array Report fragment.
     */
    private function write_site_identity() {
        $identity = $this->source->read_site_identity();

        $report = array(
            'entity'         => $identity['entity'],
            'notes'          => array_values( array_unique( (array) $identity['notes'] ) ),
            'written'        => false,
            'fields'         => array(),
            'kept'           => array(),
            'person_skipped' => false,
        );

        $stored = get_option( self::IDENTITY_OPTION, array() );
        $stored = is_array( $stored ) ? $stored : array();
        $new    = $stored;

        $is_person = ( 'person' === $identity['entity'] );

        if ( $is_person ) {
            if ( '' !== $identity['name'] || '' !== $identity['logo'] ) {
                $report['person_skipped'] = true;
            }
        } else {
            if ( '' !== $identity['name'] ) {
                if ( '' === trim( (string) ( isset( $stored['organization_name'] ) ? $stored['organization_name'] : '' ) ) ) {
                    $new['organization_name'] = sanitize_text_field( $identity['name'] );
                    $report['fields'][]       = 'name';
                } else {
                    $report['kept'][] = 'name';
                }
            }

            if ( '' !== $identity['logo'] ) {
                if ( '' === trim( (string) ( isset( $stored['logo_url'] ) ? $stored['logo_url'] : '' ) ) ) {
                    $new['logo_url']    = esc_url_raw( $identity['logo'] );
                    $report['fields'][] = 'logo';
                } else {
                    $report['kept'][] = 'logo';
                }
            }
        }

        if ( ! empty( $identity['profiles'] ) ) {
            $current = isset( $stored['social_profiles'] ) && is_array( $stored['social_profiles'] )
                ? TAMRANK_Schema_Settings::filter_profiles( $stored['social_profiles'] )
                : array();

            if ( empty( $current ) ) {
                $new['social_profiles'] = TAMRANK_Schema_Settings::filter_profiles(
                    array_map( 'esc_url_raw', $identity['profiles'] )
                );
                $report['fields'][]     = 'profiles';
                $report['profile_count'] = count( $new['social_profiles'] );
            } else {
                $report['kept'][] = 'profiles';
            }
        }

        if ( '' !== $identity['social_image'] ) {
            if ( '' === trim( (string) ( isset( $stored['default_social_image'] ) ? $stored['default_social_image'] : '' ) ) ) {
                $new['default_social_image']    = esc_url_raw( $identity['social_image'] );
                $new['default_social_image_id'] = absint( $identity['social_image_id'] );
                $report['fields'][]             = 'social_image';
            } else {
                $report['kept'][] = 'social_image';
            }
        }

        if ( empty( $report['fields'] ) ) {
            // Nothing to write. Not an error: it means the source carried
            // nothing we could use, or TamRank already had everything.
            return $report;
        }

        $changed = tam_rank_write_option_raw( self::IDENTITY_OPTION, $new );

        $report['written'] = true;
        $report['changed'] = (bool) $changed;

        return $report;
    }

    /**
     * The report of the last settings migration, for the wizard summary.
     *
     * @return array
     */
    public static function get_report() {
        $report = get_option( self::REPORT_OPTION, array() );

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

}
