<?php
/**
 * PK Core - projection of the core Site Profile onto the pre-core shared options.
 *
 * WHY THIS EXISTS
 * The core profile (pkcore_profile) was added alongside three older, ownerless
 * options that plugins still read directly:
 *
 *   publisherkits_site_profile   Insight Engine, ExcerptBuilder, Newsdesk, SocialPromoter
 *   publisherkits_site_context   ES Intelligence, Classifier, ExcerptBuilder, SocialPromoter
 *   publisherkits_seo_keywords   SocialPromoter
 *
 * Nothing carried a core write back out to them, so editing the Site Profile
 * page changed nothing for those readers. Measured on the estate 2026-08-23:
 * editing "Site context" on PublisherKits > Site Profile left ES Intelligence's
 * report prompt untouched, because ESI reads publisherkits_site_context and that
 * page never wrote it. Editing the same value from the ESI or SocialPromoter
 * screen did work - one value, several doors, only some of them wired.
 *
 * This class wires the remaining doors. It is the piece that lets adoption
 * proceed one plugin per release: plugins still reading the old options keep
 * working, unchanged, with no coordinated deploy.
 *
 * SCAFFOLDING, WITH AN EXIT TEST. Retire this when no plugin reads the old
 * options - PK-CORE-SPEC-01 section 5, Phase 5:
 *
 *   grep -rn "publisherkits_site_profile\|publisherkits_site_context\|publisherkits_seo_keywords" \
 *     --include=*.php . | grep -v "/pkcore/" | grep -v publisherkits-core/
 *
 * Zero hits estate-wide and this file and the legacy shim come out together.
 * NEVER RENAME the three options: they were defended to a WordPress.org
 * reviewer as deliberately shared suite data, so a rename breaks live installs
 * and reopens a settled review point. Phase 5 stops WRITING them; it does not
 * rename them.
 *
 * @package PublisherKits\Core
 * @since   0.6.0
 */

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

if ( ! class_exists( 'PKCORE_Mirror' ) ) {

	/**
	 * Class PKCORE_Mirror
	 *
	 * @since 0.6.0
	 */
	class PKCORE_Mirror {

		/**
		 * Register the projection.
		 *
		 * Deliberately NOT gated on is_admin(): PKCORE_Profile::set_many() is a
		 * public contract method callable from anywhere, and a write that skipped
		 * the projection would silently reintroduce the divergence this fixes.
		 *
		 * @return void
		 */
		public static function init() {
			add_action( 'pkcore_profile_updated', array( __CLASS__, 'project' ), 10, 3 );
		}

		/**
		 * Project changed profile values onto the pre-core options.
		 *
		 * THREE SAFETY RULES, each earned:
		 *
		 * 1. ONLY CHANGED KEYS. The hook tells us what moved; everything else is
		 *    left alone.
		 * 2. NEVER WRITE AN EMPTY VALUE OVER A POPULATED ONE. The core profile is
		 *    NOT uniformly the richer store. Measured across five live sites
		 *    2026-08-23: pkcore_profile was complete on three, partial on one
		 *    (three keys) and ABSENT on another, while publisherkits_site_profile
		 *    was complete on all five. A blanket projection would have destroyed a
		 *    complete Insight Engine profile on the two thinnest sites.
		 * 3. MERGE, NEVER REPLACE. publisherkits_site_profile is a twelve-key
		 *    array owned by nobody; writing the whole option would discard keys
		 *    this class knows nothing about.
		 *
		 * Rule 2 has ONE exception, added with $cleared: a value that was set and
		 * has been deliberately emptied. That is not "core is thinner here", it is
		 * an instruction, and refusing it left a stale paragraph feeding AI prompts
		 * in every consumer with no second source to check against. The distinction
		 * cannot be made here - only PKCORE_Profile::set_many() holds the old and
		 * new value at once - so it is passed in rather than inferred.
		 *
		 * @param string[] $changed Keys that changed.
		 * @param array    $profile The full profile after the write.
		 * @param string[] $cleared Subset of $changed deliberately emptied. Defaults
		 *                          to empty, so a pre-0.9.16 caller firing the hook
		 *                          with two arguments still gets exactly the old
		 *                          behaviour rather than a PHP error.
		 * @return void
		 */
		public static function project( $changed, $profile, $cleared = array() ) {
			$changed = is_array( $changed ) ? $changed : array();
			$profile = is_array( $profile ) ? $profile : array();
			$cleared = is_array( $cleared ) ? $cleared : array();

			if ( in_array( 'context', $changed, true ) ) {
				self::project_context( $profile, in_array( 'context', $cleared, true ) );
			}

			self::project_legacy_profile( $changed, $profile, $cleared );
		}

		/**
		 * Everything that lands in publisherkits_site_profile.
		 *
		 * EXTENDED AT CORE v0.9.3. At 0.9.2 this carried `description` alone,
		 * because the v1 vocabulary had nowhere to hold the rest: no site_type,
		 * no audience description, no goal slugs. That was recorded at the time
		 * as "not safe to project", which was the wrong description of the
		 * problem - the accurate one is that THE CORE PROFILE DID NOT CARRY THESE
		 * FIELDS YET. A mirror over a vocabulary holding two of eight facts can
		 * only ever carry two (PK-CORE-SPEC-01 section 9.7a).
		 *
		 * With the v2 vocabulary in place - and adopted VERBATIM from Insight
		 * Engine, which owns the legacy option - the projections below are 1:1.
		 * No mapping table, no lossy translation, nothing invented.
		 *
		 * @param string[] $changed Keys that changed.
		 * @param array    $profile Full profile after the write.
		 * @param string[] $cleared Subset of $changed deliberately emptied.
		 * @return void
		 */
		private static function project_legacy_profile( $changed, $profile, $cleared = array() ) {
			$legacy = get_option( 'publisherkits_site_profile', array() );
			$legacy = is_array( $legacy ) ? $legacy : array();
			$before = $legacy;

			$readers = isset( $profile['audience_readers'] ) && is_array( $profile['audience_readers'] ) ? $profile['audience_readers'] : array();
			$cap     = isset( $profile['capacity'] ) && is_array( $profile['capacity'] ) ? $profile['capacity'] : array();

			// core key => array( legacy key, value ). Same vocabularies on both
			// sides, so these are copies rather than conversions.
			$map = array(
				'description'      => array( 'site_description', isset( $profile['description'] ) ? $profile['description'] : '' ),
				'site_type'        => array( 'site_type', isset( $profile['site_type'] ) ? $profile['site_type'] : '' ),
				'primary_goal'     => array( 'primary_goal', isset( $profile['primary_goal'] ) ? $profile['primary_goal'] : '' ),
				'secondary_goals'  => array( 'secondary_goals', isset( $profile['secondary_goals'] ) ? $profile['secondary_goals'] : array() ),
				'keywords'         => array( 'seo_keywords', isset( $profile['keywords'] ) ? $profile['keywords'] : array() ),
				'audience_readers' => array( 'audience_description', isset( $readers['description'] ) ? $readers['description'] : '' ),
				'capacity'         => array( 'publishing_frequency_target', isset( $cap['posts_per_week_target'] ) ? (int) $cap['posts_per_week_target'] : 0 ),
			);

			foreach ( $map as $core_key => $target ) {
				if ( ! in_array( $core_key, $changed, true ) ) {
					continue; // Rule 1.
				}
				$legacy = self::set_if_meaningful( $legacy, $target[0], $target[1], in_array( $core_key, $cleared, true ) );
			}

			// audience_readers carries three legacy keys, not one.
			if ( in_array( 'audience_readers', $changed, true ) ) {
				$readers_cleared = in_array( 'audience_readers', $cleared, true );
				$legacy          = self::set_if_meaningful( $legacy, 'audience_sectors', isset( $readers['sectors'] ) ? $readers['sectors'] : array(), $readers_cleared );
				$legacy          = self::set_if_meaningful( $legacy, 'audience_seniority', isset( $readers['seniority'] ) ? $readers['seniority'] : array(), $readers_cleared );
			}

			// highest_value_audience is projected because NOT EVERY CONSUMER
			// BUNDLES THE CORE. ExcerptBuilder is the case in point: it is in the
			// consumer matrix and reads this profile, but it has no pkcore/ folder
			// at all, so PKCORE_Profile does not exist in its process. Anything it
			// is meant to read has to arrive through this option or not at all.
			//
			// Projected as the nested array rather than flattened: the "why" is
			// what separates "advertisers pay to reach them" from "they buy
			// subscriptions", and a reader that only got the "who" would lose the
			// distinction that makes the answer actionable.
			if ( in_array( 'highest_value_audience', $changed, true ) ) {
				$hva    = isset( $profile['highest_value_audience'] ) && is_array( $profile['highest_value_audience'] ) ? $profile['highest_value_audience'] : array();
				$legacy = self::set_if_meaningful( $legacy, 'highest_value_audience', $hva, in_array( 'highest_value_audience', $cleared, true ) );
			}

			// peers likewise - Competitor Intelligence bundles the core, but the
			// legacy option is the estate's shared surface and a non-bundling
			// reader should not be locked out of a declared answer.
			if ( in_array( 'peers', $changed, true ) ) {
				$legacy = self::set_if_meaningful( $legacy, 'peers', isset( $profile['peers'] ) ? $profile['peers'] : array(), in_array( 'peers', $cleared, true ) );
			}

			// content_goals is DERIVED, never declared - Insight Engine recomputes
			// it from primary + secondary on its own save. Recompute it here too,
			// or a save made through the core page would leave IE's copy stale and
			// disagreeing with the goals beside it.
			if ( in_array( 'primary_goal', $changed, true ) || in_array( 'secondary_goals', $changed, true ) ) {
				$primary   = isset( $legacy['primary_goal'] ) ? (string) $legacy['primary_goal'] : '';
				$secondary = isset( $legacy['secondary_goals'] ) ? (array) $legacy['secondary_goals'] : array();
				$derived   = array_values( array_filter( array_unique( array_merge( '' !== $primary ? array( $primary ) : array(), $secondary ) ) ) );
				if ( ! empty( $derived ) ) {
					$legacy['content_goals'] = $derived;
				} elseif ( in_array( 'primary_goal', $cleared, true ) || in_array( 'secondary_goals', $cleared, true ) ) {
					// Derived from nothing, but by INSTRUCTION rather than absence:
					// at least one source was deliberately cleared and neither now
					// holds a value. Leaving the previous list would keep goals the
					// operator has just removed - the same divergence as any other
					// key, and easier to miss because nobody edits content_goals
					// directly.
					$legacy['content_goals'] = array();
				}
			}

			if ( $legacy !== $before ) {
				update_option( 'publisherkits_site_profile', $legacy, false ); // Rule 3: merged, never replaced.
			}
		}

		/**
		 * Write a value only when it would not erase a populated one.
		 *
		 * Rule 2, applied uniformly. The core profile is NOT reliably the richer
		 * store: measured across the five estate sites it was complete on three,
		 * partial on one and ABSENT on another, while the legacy option was
		 * complete on all five. Projecting blindly would have destroyed a
		 * complete Insight Engine profile the first time anyone pressed Save on
		 * the thinnest site.
		 *
		 * THE ONE EXCEPTION ($is_cleared): a value that WAS set in the core profile
		 * and has been deliberately emptied. The measured evidence above is about
		 * core being THINNER than legacy - a key it never held - which is a
		 * different event from a key it held and the operator removed. Refusing
		 * the second is what left cleared values alive in the legacy option.
		 *
		 * The caller cannot work this out from $value alone; it is decided in
		 * PKCORE_Profile::set_many(), the only place the old and new values coexist.
		 *
		 * @param array  $legacy     Legacy option.
		 * @param string $key        Legacy key.
		 * @param mixed  $value      Incoming value.
		 * @param bool   $is_cleared Whether this key was deliberately emptied.
		 * @return array
		 */
		private static function set_if_meaningful( $legacy, $key, $value, $is_cleared = false ) {
			$incoming_empty = is_array( $value ) ? empty( $value ) : ( '' === trim( (string) $value ) || 0 === $value );
			$existing       = isset( $legacy[ $key ] ) ? $legacy[ $key ] : null;
			$existing_set   = is_array( $existing ) ? ! empty( $existing ) : ( null !== $existing && '' !== trim( (string) $existing ) && 0 !== $existing );

			if ( $incoming_empty && $existing_set && ! $is_cleared ) {
				return $legacy;
			}
			if ( $incoming_empty && $is_cleared ) {
				$legacy[ $key ] = $value; // Deliberate clear: propagate the empty.
				return $legacy;
			}
			if ( $incoming_empty && ! $existing_set ) {
				return $legacy; // Nothing to say; do not create empty keys.
			}

			$legacy[ $key ] = $value;
			return $legacy;
		}

		/**
		 * context -> publisherkits_site_context (whole option, a plain string).
		 *
		 * @param array $profile    Full profile.
		 * @param bool  $is_cleared Whether context was deliberately emptied.
		 * @return void
		 */
		private static function project_context( $profile, $is_cleared = false ) {
			$value = isset( $profile['context'] ) ? trim( (string) $profile['context'] ) : '';
			if ( '' === $value && ! $is_cleared ) {
				return; // Rule 2.
			}
			if ( $value === (string) get_option( 'publisherkits_site_context', '' ) ) {
				return;
			}
			update_option( 'publisherkits_site_context', $value, false );
		}


		/*
		 * ------------------------------------------------------------------
		 * DELIBERATELY NOT PROJECTED - each of these looks mappable and is not.
		 * ------------------------------------------------------------------
		 *
		 * keywords -> publisherkits_seo_keywords
		 *   Shape mismatch and a casing hazard. The legacy option is
		 *   { primary: [], secondary: [] }, a split the operator curates in
		 *   SocialPromoter; the core list is flat. The core contract also
		 *   LOWER-CASES keywords, and feeding those back produced a permanently
		 *   mixed-case A-Z tag index once already (see the note at
		 *   publisherkits-tagwise/includes/pro/class-tagwise-derive.php:1560).
		 *   No projection is needed anyway: SocialPromoter already falls back to
		 *   the core profile's keywords when its own are unset (SP 1.51.1).
		 *
		 * goals -> publisherkits_site_profile['primary_goal'/'secondary_goals']
		 *   Lossy and ambiguous in this direction. The legacy vocabulary is
		 *   specific slugs (organic_traffic, advertiser_results, ...); the core's
		 *   is a small free vocabulary (grow, monetise, ...). PKCORE_Legacy_Profile
		 *   maps legacy -> core because that direction collapses cleanly. The
		 *   reverse would have to invent which of five monetisation goals
		 *   "monetise" meant, and would overwrite a real answer with a guess.
		 *
		 * audience -> publisherkits_site_profile['audience_description']
		 *   Different objects with the same name. The core key is an enum
		 *   (b2b|b2c|mixed); the legacy key is a free-text description of who
		 *   reads the site. Projecting "b2b" into it would destroy a paragraph
		 *   and replace it with a token.
		 */
	}
}
