<?php
/**
 * PK Core - SITE PROFILE contract facade (PKCORE_Profile).
 *
 * One site-level profile, core-owned, stored in the pkcore_profile option and
 * readable by every plugin. This class is a PUBLIC CONTRACT surface
 * (PK-CORE-SPEC-00 section 5): method names, argument shapes and returned key
 * names are stable from v1 and only ever grow additively. Every method is
 * safe to call the moment the class exists - empty/default structures before
 * population, never errors.
 *
 * Field vocabulary v1 (locked keys): sector, audience, description, keywords,
 * house_style { language, tone_notes }, channels, goals, context. Unknown
 * keys are permitted and preserved (forward compatibility).
 *
 * @package PublisherKits\Core
 * @since   0.1.0
 */

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

if ( ! class_exists( 'PKCORE_Profile' ) ) {

	/**
	 * Class PKCORE_Profile
	 */
	class PKCORE_Profile {

		/**
		 * Option holding the profile.
		 */
		const OPTION = 'pkcore_profile';

		/**
		 * Contract version. Increments only when methods or locked keys are
		 * ADDED (PK-CORE-SPEC-00 section 11.3).
		 *
		 * @return int
		 */
		public static function contract_version() {
			return 2;
		}

		/**
		 * Default (empty) profile.
		 *
		 * v1 keys are unchanged in name, type and meaning - SPEC-00 section 11
		 * permits keys to be ADDED and never retyped. Everything below the v2
		 * marker is additive: a consumer written against v1 keeps working
		 * untouched, and one written against v2 degrades to empty values on a
		 * site running an older core.
		 *
		 * @return array
		 */
		public static function defaults() {
			return array(
				// ---- v1, locked ------------------------------------------------
				'sector'                 => '',
				'audience'               => '',
				'description'            => '',
				'keywords'               => array(),
				'house_style'            => array(
					'language'   => '',
					'tone_notes' => '',
				),
				'channels'               => array(),
				// DEPRECATED at v2, retained because v1 keys are never removed.
				// Superseded by primary_goal / secondary_goals, which carry the
				// eleven-slug vocabulary rather than the four coarse values this
				// held. No consumer reads it, so it is no longer written; it is
				// present only so a v1 reader never sees a missing key.
				'goals'                  => array(),
				'context'                => '',

				// ---- v2, additive ----------------------------------------------
				'site_type'              => '',
				'topics_declared'        => array(),
				'exclusions'             => array(),
				'formats'                => array(),
				'editorial_rules'        => array(),
				'audience_readers'       => array(
					'description' => '',
					'sectors'     => array(),
					'seniority'   => array(),
				),
				'audience_buyers'        => array(
					'description'    => '',
					'what_they_buy'  => '',
					'good_outcome'   => '',
				),
				'audience_other'         => array(),
				'primary_goal'           => '',
				'secondary_goals'        => array(),
				'capacity'               => array(
					'posts_per_week_target' => 0,
					'sources'               => array(),
				),
				'commercial_model'       => array(),
				'highest_value_audience' => array(
					'who' => '',
					'why' => '',
				),
				'commercial_targets'     => '',
				'peers'                  => array(),
				'targets'                => '',
				// Free text about direction. Had NO default and NO sanitise case:
				// it read back as null rather than '', and fell through to the
				// scalar fallback, which is sanitize_text_field - so every newline
				// was stripped from the one field whose own help text asks for
				// "plans, pressures, a change of direction". Measured on a live
				// site 2026-08-25: a 1,148-character vision note arrived as a
				// single paragraph, while `targets` beside it kept its breaks.
				'vision_notes'           => '',
				// The IDEAL axis (PK-CORE-SPEC-01 section 2). SPARSE by design:
				// a key is present only where the desired answer DIFFERS from the
				// declared one, so an absent key means "no change intended" - a
				// real answer, not a blank. The set of keys present here is the
				// publisher's strategy brief, enumerable.
				'ideal'                  => array(),
				// Per-section provenance: section => array( updated_at, updated_by ).
				// A profile true eighteen months ago poisons everything downstream
				// and nothing recorded when it was last true.
				'_meta'                  => array(),
			);
		}

		/**
		 * The whole profile, defaults merged, unknown keys preserved.
		 *
		 * @return array
		 */
		public static function all() {
			$stored = get_option( self::OPTION, array() );
			if ( ! is_array( $stored ) ) {
				$stored = array();
			}
			$profile = array_merge( self::defaults(), $stored );
			// house_style is a nested shape; merge its defaults too.
			if ( ! is_array( $profile['house_style'] ) ) {
				$profile['house_style'] = array();
			}
			$profile['house_style'] = array_merge(
				self::defaults()['house_style'],
				$profile['house_style']
			);
			return $profile;
		}

		/**
		 * One profile value.
		 *
		 * @param string $key     Profile key.
		 * @param mixed  $default Returned when the key is absent.
		 * @return mixed
		 */
		public static function get( $key, $default = '' ) {
			$profile = self::all();
			return array_key_exists( $key, $profile ) ? $profile[ $key ] : $default;
		}

		/**
		 * The site's sector.
		 *
		 * @return string
		 */
		public static function get_sector() {
			return (string) self::get( 'sector', '' );
		}

		/**
		 * The SEO/target keyword vocabulary (lower-cased, deduped).
		 *
		 * @return string[]
		 */
		public static function get_keywords() {
			$keywords = self::get( 'keywords', array() );
			return is_array( $keywords ) ? array_values( $keywords ) : array();
		}

		/**
		 * The house style: { language, tone_notes }.
		 *
		 * @return array
		 */
		public static function get_house_style() {
			$style = self::get( 'house_style', array() );
			return is_array( $style ) ? $style : self::defaults()['house_style'];
		}

		/**
		 * The free-text context blob (for AI prompts and summaries).
		 *
		 * @return string
		 */
		public static function get_context() {
			return (string) self::get( 'context', '' );
		}

		/* ------------------------------------------------------------------
		 * v2 additions. Every one is new; no v1 method changed.
		 * ---------------------------------------------------------------- */

		/**
		 * A DECLARED value - what the publisher says is true now.
		 *
		 * Identical to get(). It exists so call sites read unambiguously
		 * alongside ideal(), because "get" does not say which axis it means and
		 * the distinction is the whole point of the model.
		 *
		 * @param string $key     Profile key.
		 * @param mixed  $default Returned when absent.
		 * @return mixed
		 */
		public static function declared( $key, $default = '' ) {
			return self::get( $key, $default );
		}

		/**
		 * An IDEAL value - what the publisher wants to become.
		 *
		 * Sparse: returns $default when no ideal is recorded, which means "no
		 * change intended" and is a real answer. Callers must NOT treat an
		 * absent ideal as an unanswered question.
		 *
		 * @param string $key     Profile key.
		 * @param mixed  $default Returned when no ideal is set.
		 * @return mixed
		 */
		public static function ideal( $key, $default = '' ) {
			$ideals = self::ideals();
			return array_key_exists( (string) $key, $ideals ) ? $ideals[ (string) $key ] : $default;
		}

		/**
		 * Every recorded ideal. The keys present ARE the strategy brief.
		 *
		 * @return array
		 */
		public static function ideals() {
			$ideals = self::get( 'ideal', array() );
			return is_array( $ideals ) ? $ideals : array();
		}

		/**
		 * Whether an ideal differs from the declared answer for this key.
		 *
		 * @param string $key Profile key.
		 * @return bool
		 */
		public static function has_ideal( $key ) {
			return array_key_exists( (string) $key, self::ideals() );
		}

		/**
		 * Reader audience: { description, sectors, seniority }.
		 *
		 * @return array
		 */
		public static function get_audience_readers() {
			$value = self::get( 'audience_readers', array() );
			return is_array( $value ) ? array_merge( self::defaults()['audience_readers'], $value ) : self::defaults()['audience_readers'];
		}

		/**
		 * The commercially most valuable slice of the audience, and why.
		 *
		 * Deliberately NOT phrased around advertisers: a subscription title's
		 * highest-value reader is the one who subscribes, an events business's
		 * is the one who buys a ticket. commercial_models carries twenty
		 * entries and this field has to mean something for all of them.
		 *
		 * @return array{who:string,why:string}
		 */
		public static function get_highest_value_audience() {
			$value = self::get( 'highest_value_audience', array() );
			return is_array( $value ) ? array_merge( self::defaults()['highest_value_audience'], $value ) : self::defaults()['highest_value_audience'];
		}

		/**
		 * Commercial audience: { description, what_they_buy, good_outcome }.
		 *
		 * The publication's BUYERS, distinct from its readers. Nothing modelled
		 * them before v2, on an estate whose stated primary goal is delivering
		 * results for advertisers.
		 *
		 * @return array
		 */
		public static function get_audience_buyers() {
			$value = self::get( 'audience_buyers', array() );
			return is_array( $value ) ? array_merge( self::defaults()['audience_buyers'], $value ) : self::defaults()['audience_buyers'];
		}

		/**
		 * Editorial rules that apply to everything the publication produces.
		 *
		 * Publication-wide only. A rule true of one output format belongs to the
		 * plugin that produces it and COMPOSES with these - see PK-CORE-SPEC-01
		 * section 4.0.
		 *
		 * @return string[]
		 */
		public static function get_editorial_rules() {
			$value = self::get( 'editorial_rules', array() );
			return is_array( $value ) ? array_values( $value ) : array();
		}

		/**
		 * Topics the publisher DECLARES they cover.
		 *
		 * Not to be confused with the topics the Classifier MEASURES. The
		 * distance between the two is the product; merging them destroys it.
		 *
		 * @return string[]
		 */
		public static function get_topics_declared() {
			$value = self::get( 'topics_declared', array() );
			return is_array( $value ) ? array_values( $value ) : array();
		}

		/**
		 * What the publication explicitly does NOT cover.
		 *
		 * @return string[]
		 */
		public static function get_exclusions() {
			$value = self::get( 'exclusions', array() );
			return is_array( $value ) ? array_values( $value ) : array();
		}

		/**
		 * Whether any non-default value has been set.
		 *
		 * @return bool
		 */
		public static function is_populated() {
			$stored = get_option( self::OPTION, array() );
			if ( ! is_array( $stored ) || empty( $stored ) ) {
				return false;
			}
			$defaults = self::defaults();
			foreach ( $stored as $key => $value ) {
				if ( ! array_key_exists( $key, $defaults ) ) {
					return true; // Unknown key someone chose to set.
				}
				if ( $value !== $defaults[ $key ] && ! ( '' === $value || array() === $value ) ) {
					return true;
				}
			}
			return false;
		}

		/**
		 * Set one profile value. Last write wins; unknown keys permitted.
		 *
		 * @param string $key   Profile key.
		 * @param mixed  $value Value (sanitised per known key).
		 * @return bool True when stored.
		 */
		public static function set( $key, $value ) {
			return self::set_many( array( (string) $key => $value ) );
		}

		/**
		 * Set several profile values in one write.
		 *
		 * @param array $pairs key => value.
		 * @return bool True when stored.
		 */
		public static function set_many( $pairs ) {
			if ( ! is_array( $pairs ) || empty( $pairs ) ) {
				return false;
			}
			$stored = get_option( self::OPTION, array() );
			if ( ! is_array( $stored ) ) {
				$stored = array();
			}
			$changed = array();
			$cleared = array();
			foreach ( $pairs as $key => $value ) {
				$key = (string) $key;
				if ( '' === $key ) {
					continue;
				}
				$value = self::sanitise( $key, $value );
				if ( ! array_key_exists( $key, $stored ) || $stored[ $key ] !== $value ) {
					// A DELIBERATE CLEAR is not the same event as an absent value,
					// and this is the only place in the estate where both the old and
					// the new value are in hand to tell them apart. Downstream -
					// PKCORE_Mirror in particular - sees only the new value, so an
					// empty arriving there is ambiguous: it could be an operator
					// emptying a field they had filled in, or a blank box on a form
					// that submits every field whether or not it was ever populated.
					//
					// Treating those two the same is what produced the empty-clear
					// divergence: the mirror refuses every empty (Rule 2), so a
					// cleared value never reaches the legacy option and a stale
					// paragraph keeps feeding AI prompts in consumers that have no
					// second source. ExcerptBuilder is the case in point - it does
					// not bundle the core, so the projected option is its only route.
					//
					// Rule 2 is NOT the bug and must stay: the form submits blanks
					// for fields core has never held, and projecting those would wipe
					// a complete legacy profile on a thin-core site. Only a value
					// that WAS set and is now empty is an unambiguous clear.
					$was_set = array_key_exists( $key, $stored ) && ! self::is_empty_value( $stored[ $key ] );
					if ( $was_set && self::is_empty_value( $value ) ) {
						$cleared[] = $key;
					}

					$stored[ $key ] = $value;
					$changed[]      = $key;
				}
			}
			if ( empty( $changed ) ) {
				return true; // Nothing to write; not an error.
			}
			update_option( self::OPTION, $stored, false );

			/**
			 * Fires when profile values change.
			 *
			 * The third argument is ADDITIVE and was added alongside it, not in
			 * place of anything: hook argument ORDER is contract (SPEC-00 s11), so
			 * existing two-argument listeners keep working untouched.
			 *
			 * @since 0.1.0
			 * @param string[] $changed The keys that changed.
			 * @param array    $profile The full profile after the write.
			 * @param string[] $cleared Subset of $changed that were DELIBERATELY
			 *                          emptied - previously set, now empty. A key
			 *                          absent here whose value is empty was never
			 *                          populated, which is a different event and
			 *                          must not overwrite a populated legacy value.
			 */
			do_action( 'pkcore_profile_updated', $changed, self::all(), $cleared );
			return true;
		}

		/**
		 * Is a profile value empty, for the purpose of deciding a deliberate clear?
		 *
		 * Matches PKCORE_Mirror::set_if_meaningful()'s test deliberately, including
		 * treating integer 0 as empty - publishing_frequency_target is an int and a
		 * 0 there means "not stated", not "zero posts a week". The two tests must
		 * agree or a key could be reported cleared and then refused by the mirror,
		 * which would be worse than either behaviour alone.
		 *
		 * @param mixed $value Value.
		 * @return bool
		 */
		private static function is_empty_value( $value ) {
			if ( is_array( $value ) ) {
				return empty( $value );
			}
			return ( '' === trim( (string) $value ) || 0 === $value );
		}

		/**
		 * Whether a sanitised value carries no answer at all. MUST RECURSE.
		 *
		 * The ideal axis is sparse BY CONTRACT: a key is present only where the
		 * intention differs from the declared answer, so the set of keys present
		 * IS the strategy brief. That invariant needs a correct emptiness test,
		 * and `'' === $x || array() === $x` is not one.
		 *
		 * A blank NESTED value sanitises to a populated array of blanks -
		 * array( 'who' => '', 'why' => '' ) - which is neither '' nor array(),
		 * so it survived and was written. `ideal` is itself a nested question
		 * whose sub-fields mirror every ideal-capable question, and a text input
		 * always posts, so EVERY save of the Site Profile page wrote a blank
		 * ideal for all four nested ideal-capable keys: audience_readers,
		 * audience_buyers, capacity and highest_value_audience. The strategy
		 * brief silently listed four intended changes nobody had asked for.
		 *
		 * Third time this exact shape has bitten: the same `empty()`-on-a-keyed-
		 * array error was fixed in PKCORE_Legacy_Profile::is_empty_value() and
		 * again in PKCORE_Consumers. Those two keep their own private copies for
		 * now - consolidating means new public surface on a frozen contract, so
		 * it is a deliberate decision rather than an oversight.
		 *
		 * @param mixed $value Sanitised value.
		 * @return bool
		 */
		private static function is_blank_value( $value ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $leaf ) {
					if ( ! self::is_blank_value( $leaf ) ) {
						return false;
					}
				}
				return true;
			}
			if ( is_int( $value ) || is_float( $value ) ) {
				return ( 0 === (int) $value ); // capacity's posts_per_week_target.
			}
			return ( '' === trim( (string) $value ) );
		}

		/**
		 * Per-key sanitisation for the locked vocabulary; conservative
		 * pass-through shapes for unknown keys.
		 *
		 * @param string $key   Profile key.
		 * @param mixed  $value Raw value.
		 * @return mixed
		 */
		private static function sanitise( $key, $value ) {
			switch ( $key ) {
				case 'sector':
				case 'description':
				case 'context':
					return sanitize_textarea_field( (string) $value );
				case 'audience':
					$value = sanitize_key( (string) $value );
					return in_array( $value, array( 'b2b', 'b2c', 'mixed' ), true ) ? $value : '';
				case 'keywords':
					return self::sanitise_list( $value, true );
				case 'channels':
					return self::sanitise_list( $value, false );

				// `goals` IS A LEGACY KEY WITH A LIVE CONSUMER. Do not tighten it,
				// and do not delete it as unused. Both are tempting and both break
				// something. Measured 2026-08-27:
				//
				//  - There is NO entry for `goals` in PKCORE_Questions, so the
				//    current Site Profile page never asks it. Only the older
				//    checkbox block in class-pkcore-admin.php renders it.
				//  - It therefore holds values no current form can produce - one
				//    live site stores `grow` and `monetise`, which are not in
				//    PKCORE_Vocab::goals() and predate that vocabulary.
				//  - TagWise's Pro grounding READS it
				//    (class-tagwise-grounding.php), so it is not dead.
				//
				// Tightening this to a closed vocabulary would silently DROP those
				// stored values rather than reject them - closed-vocab keys here
				// discard unrecognised slugs by design, and set_many() reports
				// success either way. TagWise's grounding would quietly lose them
				// and nothing would report it.
				//
				// If it is ever tightened, MIGRATE THE STORED VALUES IN THE SAME
				// CHANGE. Never tighten alone.
				//
				// Note `goals` is distinct from primary_goal/secondary_goals: those
				// are the closed-vocab declared goals; this is a free-text list.
				case 'goals':
					return self::sanitise_list( $value, false );
				case 'house_style':
					$value = is_array( $value ) ? $value : array();
					return array(
						'language'   => sanitize_text_field( isset( $value['language'] ) ? (string) $value['language'] : '' ),
						'tone_notes' => sanitize_textarea_field( isset( $value['tone_notes'] ) ? (string) $value['tone_notes'] : '' ),
					);

				/* ---- v2 --------------------------------------------------- */

				// Closed vocabularies. An unknown slug is DROPPED rather than
				// stored: a stored slug with no label renders as a blank option
				// and reads as data loss. See PKCORE_Vocab.
				case 'site_type':
					$value = sanitize_key( (string) $value );
					return ( class_exists( 'PKCORE_Vocab' ) && PKCORE_Vocab::is_valid( $value, 'site_types' ) ) ? $value : '';
				case 'primary_goal':
					$value = sanitize_key( (string) $value );
					return ( class_exists( 'PKCORE_Vocab' ) && PKCORE_Vocab::is_valid( $value, 'goals' ) ) ? $value : '';
				case 'secondary_goals':
					$list = class_exists( 'PKCORE_Vocab' ) ? PKCORE_Vocab::filter_list( $value, 'goals' ) : array();
					return array_slice( $list, 0, 2 ); // Two, per the form.
				case 'formats':
					return class_exists( 'PKCORE_Vocab' ) ? PKCORE_Vocab::filter_list( $value, 'formats' ) : array();
				case 'commercial_model':
					return class_exists( 'PKCORE_Vocab' ) ? PKCORE_Vocab::filter_list( $value, 'commercial_models' ) : array();

				// Free-text lists: the publisher's own words, preserved as typed.
				case 'topics_declared':
				case 'exclusions':
				case 'editorial_rules':
				case 'peers':
				case 'audience_other':
					return self::sanitise_list( $value, false );

				case 'commercial_targets':
				case 'targets':
				case 'vision_notes':
					return sanitize_textarea_field( (string) $value );

				case 'audience_readers':
					$value = is_array( $value ) ? $value : array();
					return array(
						'description' => sanitize_textarea_field( isset( $value['description'] ) ? (string) $value['description'] : '' ),
						'sectors'     => self::sanitise_list( isset( $value['sectors'] ) ? $value['sectors'] : array(), false ),
						'seniority'   => class_exists( 'PKCORE_Vocab' ) ? PKCORE_Vocab::filter_list( isset( $value['seniority'] ) ? $value['seniority'] : array(), 'seniority' ) : array(),
					);

				case 'audience_buyers':
					$value = is_array( $value ) ? $value : array();
					return array(
						'description'   => sanitize_textarea_field( isset( $value['description'] ) ? (string) $value['description'] : '' ),
						'what_they_buy' => sanitize_textarea_field( isset( $value['what_they_buy'] ) ? (string) $value['what_they_buy'] : '' ),
						'good_outcome'  => sanitize_textarea_field( isset( $value['good_outcome'] ) ? (string) $value['good_outcome'] : '' ),
					);

				// Who is worth most COMMERCIALLY, and the mechanism of that value.
				// 'why' is not decoration: "advertisers pay to reach them" and
				// "they buy subscriptions" are different businesses needing
				// different advice, and the two are indistinguishable from 'who'.
				case 'highest_value_audience':
					$value = is_array( $value ) ? $value : array();
					return array(
						'who' => sanitize_textarea_field( isset( $value['who'] ) ? (string) $value['who'] : '' ),
						'why' => sanitize_textarea_field( isset( $value['why'] ) ? (string) $value['why'] : '' ),
					);

				case 'capacity':
					$value = is_array( $value ) ? $value : array();
					return array(
						'posts_per_week_target' => max( 0, (int) ( isset( $value['posts_per_week_target'] ) ? $value['posts_per_week_target'] : 0 ) ),
						'sources'               => class_exists( 'PKCORE_Vocab' ) ? PKCORE_Vocab::filter_list( isset( $value['sources'] ) ? $value['sources'] : array(), 'content_sources' ) : array(),
					);

				// The ideal axis. Each entry is sanitised AS THE KEY IT MIRRORS,
				// and dropped when blank - see is_blank_value(), which MUST recurse.
				// so an ideal can never hold a shape its declared twin could not.
				// Empty entries are dropped, which is what keeps it sparse:
				// clearing an ideal removes the key, restoring "no change
				// intended" rather than leaving a blank that reads as an answer.
				case 'ideal':
					$value = is_array( $value ) ? $value : array();
					$out   = array();
					foreach ( $value as $ideal_key => $ideal_value ) {
						$ideal_key = (string) $ideal_key;
						if ( '' === $ideal_key || 'ideal' === $ideal_key || '_meta' === $ideal_key ) {
							continue; // No ideal of an ideal, and no metadata.
						}
						$clean = self::sanitise( $ideal_key, $ideal_value );
						if ( self::is_blank_value( $clean ) ) {
							continue;
						}
						$out[ $ideal_key ] = $clean;
					}
					return $out;

				case '_meta':
					$value = is_array( $value ) ? $value : array();
					$out   = array();
					foreach ( $value as $section => $meta ) {
						$section = sanitize_key( (string) $section );
						if ( '' === $section || ! is_array( $meta ) ) {
							continue;
						}
						$out[ $section ] = array(
							'updated_at' => sanitize_text_field( isset( $meta['updated_at'] ) ? (string) $meta['updated_at'] : '' ),
							'updated_by' => absint( isset( $meta['updated_by'] ) ? $meta['updated_by'] : 0 ),
						);
					}
					return $out;
				default:
					if ( is_array( $value ) ) {
						return map_deep( $value, 'sanitize_text_field' );
					}
					return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : '';
			}
		}

		/**
		 * Sanitise a string list: trim, drop blanks, dedupe.
		 *
		 * @param mixed $value      Raw value (array or newline/comma string).
		 * @param bool  $lower_case Lower-case entries (keywords are stored
		 *                          lower-cased per the contract).
		 * @return string[]
		 */
		private static function sanitise_list( $value, $lower_case ) {
			if ( is_string( $value ) ) {
				$value = preg_split( '/[\r\n,]+/', $value );
			}
			if ( ! is_array( $value ) ) {
				return array();
			}
			$out = array();
			foreach ( $value as $entry ) {
				$entry = sanitize_text_field( trim( (string) $entry ) );
				if ( '' === $entry ) {
					continue;
				}
				if ( $lower_case ) {
					$entry = strtolower( $entry );
				}
				$out[] = $entry;
			}
			return array_values( array_unique( $out ) );
		}
	}
}
