<?php
/**
 * PK Core - TEMPORARY legacy-profile migration shim.
 *
 * Before the core existed, profile-ish facts accumulated in a handful of
 * shared and per-plugin options: the ownerless publisherkits_site_profile
 * (Insight Engine / SocialPromoter), publisherkits_site_context,
 * publisherkits_seo_keywords, EmailBuilder's Writing language / House style
 * notes, and ExcerptBuilder's pkeb_site_context. This shim carries them into
 * the core Site Profile ONCE, so the operator does not re-type what is
 * already known:
 *
 *   - Form PREFILL: the setup wizard and the Site Profile page render
 *     still-empty fields with the legacy values, so the operator reviews
 *     and confirms before anything becomes canonical (nothing is written
 *     silently).
 *   - FINISH CARRY-OVER: the wizard asks only its own tier of questions, so
 *     legacy values for fields it never renders - keywords, capacity,
 *     secondary_goals - would otherwise be prefilled into nothing and lost
 *     the moment the operator finished. PKCORE_Setup::save() therefore calls
 *     apply_pending() after its own save.
 *
 *     THIS WAS BROKEN FROM 0.9.3 TO 0.9.11. apply_pending() was called by
 *     class-pkcore-wizard.php, which was deleted at 0.9.3 when PKCORE_Setup
 *     replaced it; the call was not carried across, and nothing else in the
 *     estate called it. Measured 27 Aug: the only remaining callers anywhere
 *     were SocialPromoter's frozen WP.org tags, a stale worktree and
 *     pkcore-validate.php - i.e. the test harness proved a behaviour that no
 *     shipping code performed. On the estate the damage was limited because
 *     an operator eventually opens the full Site Profile page, which does
 *     prefill and save every field. For a WordPress.org user who completes
 *     the wizard and never opens the page, it is silent data loss - which is
 *     why the expiry of the "single estate" premise turns a latent gap into a
 *     real one.
 *
 * Fields that already hold a value are NEVER overwritten, so once the
 * profile is populated this whole class is inert.
 *
 * REMOVAL NOTE - REWRITTEN AT 0.9.11, BECAUSE ITS PREMISE HAS EXPIRED.
 *
 * It used to read: "this is a single-estate, one-time migration - there are no
 * external legacy users. Once the estate's live sites carry a populated core
 * profile, delete this file and its call sites."
 *
 * Both halves of that are now wrong.
 *
 *   THERE WILL BE EXTERNAL LEGACY USERS. BioBuilder (0.10.3) and
 *   ExcerptBuilder (0.12.10) are live on WordPress.org and adopt the core in
 *   tranche 2. Their existing users have answers stored in per-plugin options
 *   and have never seen a core Site Profile.
 *
 *   "ONCE THE ESTATE'S LIVE SITES ARE POPULATED" IS NO LONGER THE RIGHT TEST.
 *   The users whose data this protects are not the estate's, are not
 *   enumerable, and update on their own schedule. The correct test is now:
 *   delete this file when no SUPPORTED UPGRADE PATH still passes through a
 *   version that wrote one of the legacy options - i.e. when the oldest
 *   version anyone can upgrade FROM already writes the core profile. That is
 *   years away, not weeks, and it is a WP.org support decision rather than an
 *   estate housekeeping one.
 *
 * THREE CONSEQUENCES, worked through rather than assumed:
 *
 *   1. AN EXTERNAL USER'S FIRST SIGHT OF THE SITE PROFILE IS A MIGRATION, NOT
 *      A BLANK FORM. Prefill must therefore be self-explanatory: an estate
 *      operator knows why "Human resources" is already in the box, an
 *      ExcerptBuilder user does not. sources_labelled() exists so the screens
 *      can say WHICH plugin each prefilled value came from.
 *
 *   2. THE "IDENTICAL VOCABULARIES" SHORTCUT DIES WITH THE PREMISE. Several
 *      comments below correctly say there is nothing to map, because core
 *      adopted Insight Engine's slugs verbatim. That is true only of options
 *      written by estate plugins that co-evolved with the core. pkeb_site_context
 *      is the first source that was never co-designed with it: its site_type
 *      is FREE TEXT and core's is a closed vocabulary, so carrying it verbatim
 *      would be silently dropped by PKCORE_Profile::sanitise() while
 *      set_many() returned true. See site_type_slug() and unmapped().
 *
 *   3. A DROPPED VALUE MUST BE VISIBLE. On a single estate a lost value is
 *      noticed by the person who typed it. Externally it is not noticed at
 *      all, so pending() now separates what it CAN carry from what it cannot,
 *      and apply_pending() reports what actually landed rather than what it
 *      intended to write.
 *
 * (Corrected at 0.9.3. The old three-step class-pkcore-wizard.php was retired
 * and no longer exists; and the Site Profile PAGE now prefills as well, which
 * it did not before - so a migration used to depend on the operator happening
 * to run the wizard rather than opening the page.)
 *
 * @package PublisherKits\Core
 * @since   0.5.0
 */

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

if ( ! class_exists( 'PKCORE_Legacy_Profile' ) ) {

	/**
	 * Class PKCORE_Legacy_Profile
	 */
	class PKCORE_Legacy_Profile {


		/**
		 * The legacy values available on this site, in core-profile shape.
		 * Empty entries are omitted.
		 *
		 * @return array
		 */
		public static function sources() {
			$out = array();

			// Ownerless shared profile (Insight Engine / SocialPromoter).
			$legacy = get_option( 'publisherkits_site_profile', array() );
			$legacy = is_array( $legacy ) ? $legacy : array();

			$description = isset( $legacy['site_description'] ) ? trim( (string) $legacy['site_description'] ) : '';
			if ( '' !== $description ) {
				$out['description'] = $description;
			}

			// Context: the ownerless shared site-context option, carried across
			// VERBATIM.
			//
			// This used to APPEND "Audience: " . audience_description to the
			// context. That was wrong twice over. Audience is a different layer
			// from tone, so it contaminated a tone field with audience data; and
			// SocialPromoter's publication_context() reads the core context FIRST
			// while prompt_framing() separately appends the audience, so the
			// audience arrived TWICE in a single prompt - the exact duplication
			// prompt_framing's own docblock says it avoids. Measured on two live
			// sites 2026-08-23 and repaired there; removed here so a first run
			// on a fresh site cannot recreate it.
			$site_context = trim( (string) get_option( 'publisherkits_site_context', '' ) );
			if ( '' !== $site_context ) {
				$out['context'] = $site_context;
			}

			// Goals, carried across VERBATIM.
			//
			// This used to run every value through goal_map(), which collapsed
			// eleven legacy slugs into the core's four coarse ones - advertising,
			// advertiser_results, leads_enquiries, ecommerce and donations ALL
			// became "monetise". Five distinct commercial goals arrived as one
			// token, and the direction could not be reversed afterwards.
			//
			// Since core v2 the two vocabularies ARE the same list: core adopted
			// Insight Engine's slugs verbatim (PK-CORE-SPEC-02 section 5b), so
			// there is nothing to map. goal_map() is deleted.
			$primary = isset( $legacy['primary_goal'] ) ? sanitize_key( (string) $legacy['primary_goal'] ) : '';
			if ( '' !== $primary ) {
				$out['primary_goal'] = $primary;
			}
			$secondary = isset( $legacy['secondary_goals'] ) && is_array( $legacy['secondary_goals'] ) ? $legacy['secondary_goals'] : array();
			$secondary = array_values( array_filter( array_map( 'sanitize_key', array_map( 'strval', $secondary ) ) ) );
			if ( ! empty( $secondary ) ) {
				$out['secondary_goals'] = $secondary;
			}

			// Fields the core profile could not hold before v2.
			//
			// At v1 there was no site_type, no audience description, no seniority
			// or sector lists and no capacity, so these simply had nowhere to go
			// and the shim carried a fraction of what the estate already knew.
			// The vocabularies are now identical on both sides, so these are
			// copies rather than conversions.
			$site_type = isset( $legacy['site_type'] ) ? sanitize_key( (string) $legacy['site_type'] ) : '';
			if ( '' !== $site_type ) {
				$out['site_type'] = $site_type;
			}

			$readers = array(
				'description' => isset( $legacy['audience_description'] ) ? trim( (string) $legacy['audience_description'] ) : '',
				'sectors'     => isset( $legacy['audience_sectors'] ) && is_array( $legacy['audience_sectors'] ) ? array_values( array_filter( array_map( 'trim', array_map( 'strval', $legacy['audience_sectors'] ) ) ) ) : array(),
				'seniority'   => isset( $legacy['audience_seniority'] ) && is_array( $legacy['audience_seniority'] ) ? array_values( array_filter( array_map( 'sanitize_key', array_map( 'strval', $legacy['audience_seniority'] ) ) ) ) : array(),
			);
			if ( '' !== $readers['description'] || ! empty( $readers['sectors'] ) || ! empty( $readers['seniority'] ) ) {
				$out['audience_readers'] = $readers;
			}

			$frequency = isset( $legacy['publishing_frequency_target'] ) ? (int) $legacy['publishing_frequency_target'] : 0;
			if ( $frequency > 0 ) {
				$out['capacity'] = array(
					'posts_per_week_target' => $frequency,
					'sources'               => array(),
				);
			}

			// Ownerless shared SEO keywords (primary + secondary).
			$seo      = get_option( 'publisherkits_seo_keywords', array() );
			$seo      = is_array( $seo ) ? $seo : array();
			$keywords = array_filter(
				array_map(
					static function ( $keyword ) {
						return trim( (string) $keyword );
					},
					array_merge(
						isset( $seo['primary'] ) && is_array( $seo['primary'] ) ? $seo['primary'] : array(),
						isset( $seo['secondary'] ) && is_array( $seo['secondary'] ) ? $seo['secondary'] : array()
					)
				)
			);
			if ( ! empty( $keywords ) ) {
				$out['keywords'] = array_values( $keywords );
			}

			// EmailBuilder's Writing language ONLY.
			//
			// pkemb_ai_house_style is NO LONGER carried into house_style.tone_notes.
			// Despite its name that option holds EMAIL-FORMAT instruction, not house
			// style: measured across the five estate sites 2026-08-23, every site
			// with a value held things like "connect the stories into a coherent
			// intro rather than summarising them one after another". Carrying it in
			// would have fed newsletter formatting to every AI surface AS the
			// publication's house style - and it would have fired, because two
			// measured live sites have no house_style key at all while both hold
			// ~1,200 characters of it. The
			// publication's rules live in the Site Profile; EmailBuilder's live in
			// EmailBuilder and COMPOSE with them (PKEMB_Settings::ai_house_style
			// since 0.165.0). Writing language is kept: it is publication-wide.
			$language = trim( (string) get_option( 'pkemb_ai_language', '' ) );
			if ( '' !== $language ) {
				$out['house_style'] = array(
					'language'   => $language,
					'tone_notes' => '',
				);
			}

			// ExcerptBuilder's own "Site & audience" answers.
			//
			// ADDED AT 0.9.11, AHEAD OF EXCERPTBUILDER ADOPTING THE CORE. Its
			// duplicate Site & audience fields are removed on adoption; if this
			// option is not read first, every existing user's audience answers
			// are stranded and their excerpts quietly get worse. ExcerptBuilder
			// is live on WordPress.org, so those users are not ours to survey.
			//
			// THIS IS THE FIRST SOURCE THAT WAS NEVER CO-DESIGNED WITH THE CORE,
			// and it behaves differently from every option above. The three keys
			// do not share a fate:
			//
			// audience_description -> audience_readers.description   clean
			// site_description     -> description                    COLLIDES
			// site_type            -> site_type                      NEEDS MAPPING.
			$pkeb = get_option( 'pkeb_site_context', array() );
			$pkeb = is_array( $pkeb ) ? $pkeb : array();

			// COLLISION, resolved by a STATED RULE rather than by which
			// assignment happens to run last.
			//
			// publisherkits_site_profile['site_description'] maps to the same
			// core key, twenty lines above. That option is the estate's shared,
			// multi-plugin profile; pkeb_site_context is one plugin's private
			// copy. Where both exist the shared one is the better answer - it is
			// what Insight Engine, SocialPromoter and Newsdesk have all been
			// reading - so ExcerptBuilder's fills the gap only when the shared
			// one is absent. On an external ExcerptBuilder-only site the shared
			// option does not exist at all, which is the case this serves.
			$pkeb_description = isset( $pkeb['site_description'] ) ? trim( (string) $pkeb['site_description'] ) : '';
			if ( '' !== $pkeb_description && ! isset( $out['description'] ) ) {
				$out['description'] = $pkeb_description;
			}

			// Clean: a leaf of a nested key, merged per leaf by pending().
			// Same precedence rule - the shared option's audience wins.
			$pkeb_audience = isset( $pkeb['audience_description'] ) ? trim( (string) $pkeb['audience_description'] ) : '';
			if ( '' !== $pkeb_audience ) {
				if ( ! isset( $out['audience_readers'] ) ) {
					$out['audience_readers'] = array(
						'description' => '',
						'sectors'     => array(),
						'seniority'   => array(),
					);
				}
				if ( '' === trim( (string) $out['audience_readers']['description'] ) ) {
					$out['audience_readers']['description'] = $pkeb_audience;
				}
			}

			// THE SILENT-DROP TRAP. ExcerptBuilder stores site_type as FREE TEXT
			// (an <input type="text"> with the placeholder "e.g. B2B trade
			// publication"); core's site_type is a CLOSED VOCABULARY, and
			// PKCORE_Profile::sanitise() drops an unrecognised slug to '' while
			// set_many() returns true regardless.
			//
			// So carrying it verbatim would sanitize_key( 'B2B trade publication' )
			// to 'b2btradepublication', fail PKCORE_Vocab::is_valid(), store '',
			// and report success - AFTER showing the operator that value in the
			// prefill. This is the shape that silently lost a live site's
			// `audience` value.
			//
			// Only a confident match is carried. Anything else is NOT offered and
			// NOT claimed; it surfaces through unmapped() instead, so the operator
			// is asked rather than quietly overruled.
			$pkeb_type = isset( $pkeb['site_type'] ) ? trim( (string) $pkeb['site_type'] ) : '';
			if ( '' !== $pkeb_type && ! isset( $out['site_type'] ) ) {
				$slug = self::site_type_slug( $pkeb_type );
				if ( '' !== $slug ) {
					$out['site_type'] = $slug;
				}
			}

			return $out;
		}

		/**
		 * Legacy values that exist but CANNOT be carried, with the reason.
		 *
		 * "We could not carry this" is not the same as "there was nothing to
		 * carry", and on a single estate the difference was invisible because
		 * the person who typed the value would notice it missing. External
		 * users will not. The screens use this to ASK rather than to guess.
		 *
		 * @since 0.9.11
		 * @return array<string,array{value:string,reason:string,source:string}>
		 */
		public static function unmapped() {
			$out  = array();
			$pkeb = get_option( 'pkeb_site_context', array() );
			$pkeb = is_array( $pkeb ) ? $pkeb : array();

			$pkeb_type = isset( $pkeb['site_type'] ) ? trim( (string) $pkeb['site_type'] ) : '';
			if ( '' !== $pkeb_type && '' === self::site_type_slug( $pkeb_type ) ) {
				$profile = PKCORE_Profile::all();
				if ( '' === trim( (string) ( isset( $profile['site_type'] ) ? $profile['site_type'] : '' ) ) ) {
					$out['site_type'] = array(
						'value'  => $pkeb_type,
						'reason' => __( 'ExcerptBuilder recorded this as free text and it does not match one of the site types the core offers. Choose the closest match - it has NOT been filled in for you, because guessing it wrong would be worse than leaving it blank.', 'publisherkits-socialpromoter' ),
						'source' => 'ExcerptBuilder',
					);
				}
			}

			return $out;
		}

		/**
		 * Map ExcerptBuilder's free-text site type onto the core vocabulary.
		 *
		 * Returns '' when there is no confident match. DELIBERATELY
		 * CONSERVATIVE: a wrong site_type silently changes what every
		 * PublisherKits plugin thinks this publication is, and the operator
		 * has no reason to re-check a field that was filled in for them.
		 *
		 * NEVER RETURNS 'other'. 'other' is a real answer meaning "none of
		 * these fit", not a bucket for "we could not parse this" - using it as
		 * a fallback would convert an unanswered question into a confident
		 * wrong answer, which is the whole failure being avoided.
		 *
		 * @since 0.9.11
		 * @param string $text Free-text site type.
		 * @return string Core site_type slug, or '' when unmappable.
		 */
		public static function site_type_slug( $text ) {
			$text = trim( (string) $text );
			if ( '' === $text || ! class_exists( 'PKCORE_Vocab' ) ) {
				return '';
			}

			// 1. Already a valid slug (a user who typed one, or a later version
			// of ExcerptBuilder that stores slugs).
			$as_slug = sanitize_key( $text );
			if ( PKCORE_Vocab::is_valid( $as_slug, 'site_types' ) ) {
				return $as_slug;
			}

			$normalised = strtolower( preg_replace( '/[^a-z0-9]+/i', ' ', $text ) );
			$normalised = trim( preg_replace( '/\s+/', ' ', $normalised ) );

			// 2. Exact match on the vocabulary's own label.
			foreach ( PKCORE_Vocab::options( 'site_types' ) as $slug => $label ) {
				$label_norm = strtolower( preg_replace( '/[^a-z0-9]+/i', ' ', (string) $label ) );
				$label_norm = trim( preg_replace( '/\s+/', ' ', $label_norm ) );
				if ( '' !== $label_norm && $label_norm === $normalised ) {
					return $slug;
				}
			}

			// 3. Distinctive phrases, ordered MOST SPECIFIC FIRST. Order is
			// load-bearing: "b2b trade magazine" contains both "b2b" and
			// "magazine", and it is a trade publication, not a consumer one.
			$phrases = array(
				'b2b_publication'   => array( 'b2b', 'business to business', 'trade publication', 'trade magazine', 'trade press', 'trade title', 'industry news', 'industry publication', 'professional publication' ),
				'charity_ngo'       => array( 'charity', 'ngo', 'non profit', 'nonprofit', 'not for profit', 'third sector' ),
				'education'         => array( 'education', 'school', 'university', 'college', 'learning', 'course', 'training provider' ),
				'ecommerce'         => array( 'ecommerce', 'e commerce', 'online shop', 'online store', 'webshop' ),
				'community'         => array( 'community', 'membership', 'forum', 'association' ),
				'portfolio'         => array( 'portfolio', 'personal brand', 'freelance' ),
				'hobbyist'          => array( 'hobby', 'hobbyist', 'enthusiast', 'fan site' ),
				'consumer_magazine' => array( 'consumer magazine', 'lifestyle', 'consumer title', 'b2c', 'magazine' ),
				'news'              => array( 'news site', 'local news', 'newspaper', 'news outlet', 'news' ),
				'corporate'         => array( 'corporate', 'company website', 'company blog', 'business website' ),
				'blog'              => array( 'personal blog', 'expert blog', 'blog' ),
			);

			foreach ( $phrases as $slug => $needles ) {
				foreach ( $needles as $needle ) {
					if ( false !== strpos( $normalised, $needle ) ) {
						return $slug;
					}
				}
			}

			// No confident match. Say nothing rather than guess.
			return '';
		}

		/**
		 * Pending values annotated with the plugin each came from.
		 *
		 * An estate operator knows why a box is already filled in. An external
		 * ExcerptBuilder user does not, and prefill they cannot account for
		 * reads as the plugin having invented an answer. The screens use this
		 * to attribute each prefilled value.
		 *
		 * @since 0.9.11
		 * @return array<string,array{value:mixed,source:string}>
		 */
		public static function sources_labelled() {
			$origins = array();

			$legacy = get_option( 'publisherkits_site_profile', array() );
			if ( is_array( $legacy ) && ! empty( $legacy ) ) {
				foreach ( array( 'description', 'site_type', 'primary_goal', 'secondary_goals', 'audience_readers', 'capacity' ) as $key ) {
					$origins[ $key ] = __( 'your existing PublisherKits site profile', 'publisherkits-socialpromoter' );
				}
			}
			if ( '' !== trim( (string) get_option( 'publisherkits_site_context', '' ) ) ) {
				$origins['context'] = __( 'your existing PublisherKits site context', 'publisherkits-socialpromoter' );
			}
			$seo = get_option( 'publisherkits_seo_keywords', array() );
			if ( is_array( $seo ) && ! empty( $seo ) ) {
				$origins['keywords'] = __( 'your existing PublisherKits SEO keywords', 'publisherkits-socialpromoter' );
			}
			if ( '' !== trim( (string) get_option( 'pkemb_ai_language', '' ) ) ) {
				$origins['house_style'] = __( 'EmailBuilder', 'publisherkits-socialpromoter' );
			}

			// ExcerptBuilder only claims a key it actually supplied - the
			// precedence rule in sources() means it may have been outranked.
			$pkeb = get_option( 'pkeb_site_context', array() );
			$pkeb = is_array( $pkeb ) ? $pkeb : array();
			if ( ! empty( $pkeb ) ) {
				$shared      = get_option( 'publisherkits_site_profile', array() );
				$shared      = is_array( $shared ) ? $shared : array();
				$shared_desc = isset( $shared['site_description'] ) ? trim( (string) $shared['site_description'] ) : '';
				if ( '' === $shared_desc && '' !== trim( (string) ( isset( $pkeb['site_description'] ) ? $pkeb['site_description'] : '' ) ) ) {
					$origins['description'] = __( 'ExcerptBuilder', 'publisherkits-socialpromoter' );
				}
				if ( '' === trim( (string) ( isset( $shared['audience_description'] ) ? $shared['audience_description'] : '' ) ) && '' !== trim( (string) ( isset( $pkeb['audience_description'] ) ? $pkeb['audience_description'] : '' ) ) ) {
					$origins['audience_readers'] = __( 'ExcerptBuilder', 'publisherkits-socialpromoter' );
				}
				if ( ! isset( $shared['site_type'] ) || '' === trim( (string) $shared['site_type'] ) ) {
					if ( '' !== self::site_type_slug( isset( $pkeb['site_type'] ) ? $pkeb['site_type'] : '' ) ) {
						$origins['site_type'] = __( 'ExcerptBuilder', 'publisherkits-socialpromoter' );
					}
				}
			}

			$out = array();
			foreach ( self::pending() as $key => $value ) {
				$out[ $key ] = array(
					'value'  => $value,
					'source' => isset( $origins[ $key ] ) ? $origins[ $key ] : __( 'your existing settings', 'publisherkits-socialpromoter' ),
				);
			}
			return $out;
		}

		/**
		 * Whether a profile value counts as unanswered.
		 *
		 * MUST RECURSE. This used to be `empty( $current )` for arrays, which is
		 * false for any array that HAS KEYS - and every v2 nested default has
		 * keys, e.g. audience_readers is
		 * array( 'description' => '', 'sectors' => array(), 'seniority' => array() ).
		 * So a completely blank audience read as "already answered" and the shim
		 * silently declined to carry it. On a fresh site that meant the audience,
		 * capacity and house style the estate already knew were never offered -
		 * the migration would have looked like it ran and moved almost nothing.
		 *
		 * Caught by a test asserting a carried value survived the round trip; the
		 * value was never carried at all.
		 *
		 * @param mixed $value Value.
		 * @return bool
		 */
		private static function is_empty_value( $value ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $leaf ) {
					if ( ! self::is_empty_value( $leaf ) ) {
						return false;
					}
				}
				return true;
			}
			return ( '' === trim( (string) $value ) );
		}

		/**
		 * Legacy values for profile fields that are still empty: what a
		 * migration would actually change. Empty when the profile is already
		 * populated field-by-field (the shim goes inert naturally).
		 *
		 * @return array Map profile-key => value to carry over.
		 */
		public static function pending() {
			$profile = PKCORE_Profile::all();
			$pending = array();

			foreach ( self::sources() as $key => $value ) {
				$current = isset( $profile[ $key ] ) ? $profile[ $key ] : '';

				// NESTED KEYS MERGE PER LEAF, NEVER PER KEY.
				//
				// is_empty_value() recurses correctly, but asking it about a whole
				// nested key answers the wrong question: audience_readers is
				// { description, sectors, seniority }, so ONE populated leaf made
				// the entire key read as answered and its still-empty siblings
				// were never offered.
				//
				// Measured on a live site 2026-08-27: thirteen audience sectors,
				// three seniority tiers and twelve keywords sat in the legacy
				// option while the profile had only a populated
				// audience_readers.description - and the shim declined to carry
				// any of them. They had to be moved by hand.
				//
				// house_style used to have a bespoke per-leaf branch here doing
				// exactly this for two named sub-keys. That was the same bug
				// solved once, locally; every other nested key (audience_readers,
				// audience_buyers, capacity, highest_value_audience) still had it.
				// The special case is gone - this IS the general case.
				if ( self::is_nested( $value ) ) {
					$merged  = is_array( $current ) ? $current : array();
					$changed = false;
					foreach ( $value as $sub => $sub_value ) {
						$held = isset( $merged[ $sub ] ) ? $merged[ $sub ] : '';
						if ( self::is_unanswered_leaf( $held ) && ! self::is_unanswered_leaf( $sub_value ) ) {
							$merged[ $sub ] = $sub_value;
							$changed        = true;
						}
					}
					if ( $changed ) {
						$pending[ $key ] = $merged;
					}
					continue;
				}

				if ( self::is_empty_value( $current ) ) {
					$pending[ $key ] = $value;
				}
			}

			return $pending;
		}

		/**
		 * Is this a nested field (an associative map of sub-fields) rather than
		 * a flat list?
		 *
		 * A flat list - keywords, peers, channels - carries whole: its members
		 * are values, not answers to separate questions. A nested map carries
		 * per leaf, because each leaf IS a separate question.
		 *
		 * @since 0.9.9
		 * @param mixed $value Source value.
		 * @return bool
		 */
		private static function is_nested( $value ) {
			if ( ! is_array( $value ) || array() === $value ) {
				return false;
			}
			return array_keys( $value ) !== range( 0, count( $value ) - 1 );
		}

		/**
		 * Whether ONE leaf counts as unanswered.
		 *
		 * Differs from is_empty_value() in one respect, deliberately: a numeric
		 * zero counts as unanswered. The only numeric leaf here is
		 * capacity.posts_per_week_target, which is a TARGET - zero is how "not
		 * set" is stored, not a publisher declaring they intend to publish
		 * nothing. Treating it as answered would let a stored 0 block a real
		 * legacy figure from ever carrying.
		 *
		 * @since 0.9.9
		 * @param mixed $value Leaf value.
		 * @return bool
		 */
		private static function is_unanswered_leaf( $value ) {
			if ( is_int( $value ) || is_float( $value ) ) {
				return ( 0 === (int) $value );
			}
			return self::is_empty_value( $value );
		}

		/**
		 * A profile array with still-empty fields filled from the legacy
		 * sources - for FORM PREFILL only (the operator reviews and saves;
		 * nothing is written here).
		 *
		 * @param array $profile PKCORE_Profile::all().
		 * @return array
		 */
		public static function with_defaults( $profile ) {
			foreach ( self::pending() as $key => $value ) {
				$profile[ $key ] = $value;
			}
			return $profile;
		}

		/**
		 * Write every still-pending legacy value to the profile. Called by
		 * PKCORE_Setup::save() after the wizard's own save, so legacy values
		 * for fields the wizard never renders are not lost.
		 *
		 * VERIFIES THE EFFECT, NOT THE ATTEMPT. This used to return
		 * self::pending() - what it INTENDED to write - immediately after
		 * calling set_many(). That is not the same thing, because set_many()
		 * returns true whether or not a value survived sanitisation: a closed
		 * vocabulary key DROPS an unrecognised slug to '' by design. So a
		 * dropped value was reported to the caller as carried, and the caller
		 * showed the operator a confirmation for data that no longer existed.
		 *
		 * The profile is therefore re-read after the write and each value
		 * compared against what landed. Only what is genuinely stored is
		 * reported as written.
		 *
		 * @return array The values that were ACTUALLY written (empty when none).
		 */
		public static function apply_pending() {
			$report = self::apply_pending_report();
			return $report['written'];
		}

		/**
		 * The apply_pending() write, with the drops made visible.
		 *
		 * `dropped` should always be empty: sources() refuses to offer a value
		 * it cannot carry. It is reported anyway because "always empty" is a
		 * belief, and a guard that has never fired proves nothing - a
		 * non-empty `dropped` on a live site is the first sign that a new
		 * legacy source has the free-text-into-closed-vocabulary problem that
		 * pkeb_site_context.site_type has.
		 *
		 * @since 0.9.11
		 * @return array{written:array,dropped:array}
		 */
		public static function apply_pending_report() {
			$pending = self::pending();
			if ( empty( $pending ) ) {
				return array(
					'written' => array(),
					'dropped' => array(),
				);
			}

			PKCORE_Profile::set_many( $pending );

			// READ BACK. This is the whole point of the method.
			$after   = PKCORE_Profile::all();
			$written = array();
			$dropped = array();

			foreach ( $pending as $key => $intended ) {
				$stored = array_key_exists( $key, $after ) ? $after[ $key ] : null;

				if ( self::is_empty_value( $intended ) ) {
					continue; // Nothing was being asked for.
				}

				if ( null === $stored || self::is_empty_value( $stored ) ) {
					$dropped[ $key ] = $intended;
					continue;
				}

				// A nested key is written per leaf, so the stored shape can
				// legitimately differ from the intended one while still having
				// carried the answer. Compare the leaves that were asked for.
				if ( is_array( $intended ) && is_array( $stored ) ) {
					$lost = array();
					foreach ( $intended as $sub => $sub_value ) {
						if ( self::is_empty_value( $sub_value ) ) {
							continue;
						}
						$held = isset( $stored[ $sub ] ) ? $stored[ $sub ] : null;
						if ( null === $held || self::is_empty_value( $held ) ) {
							$lost[ $sub ] = $sub_value;
						}
					}
					if ( ! empty( $lost ) ) {
						$dropped[ $key ] = $lost;
					}
					$written[ $key ] = $stored;
					continue;
				}

				$written[ $key ] = $stored;
			}

			if ( ! empty( $dropped ) ) {
				/**
				 * Fires when a legacy value was offered to the profile and did
				 * not survive the write.
				 *
				 * @since 0.9.11
				 * @param array $dropped key => intended value.
				 */
				do_action( 'pkcore_legacy_profile_dropped', $dropped );
			}

			return array(
				'written' => $written,
				'dropped' => $dropped,
			);
		}
	}
}
