<?php
/**
 * PK Core - the Site Profile ability. The first consumer of PKCORE_Abilities.
 *
 * WHY THIS ONE, AND WHY IT LIVES IN CORE
 * A helper with no consumer delivers nothing, however correct it is: the
 * estate has shipped two of those this month. This is the consumer that
 * proves the path end to end, and it belongs in core rather than in a plugin
 * for three reasons:
 *
 *   - the Site Profile is core-owned data, so no plugin has to be touched;
 *   - it wraps PKCORE_Profile, a DECLARED CONTRACT, which is the spec's
 *     governing rule: wrap the contract, never the table;
 *   - it is the first thing an agent needs. Every other answer in the suite
 *     is about a site whose purpose, audience and voice are described here.
 *
 * WHY IT IS TIER `content` AND NOT `settings`
 * A contributor writing a post legitimately needs the publication's
 * description, audience and house style. They do not need its revenue
 * targets. So this ability returns the EDITORIAL half of the profile only,
 * and the commercial fields - commercial_model, commercial_targets,
 * highest_value_audience, audience_buyers - are deliberately absent. They
 * belong to a future tier `commercial` ability, gated separately. Returning
 * the whole profile at edit_posts would be the one-admin-gate mistake
 * inverted: a single tier leaking everything instead of locking everything.
 *
 * WHAT IT DEMONSTRATES
 * All four envelope statuses are reachable here, which is what makes the
 * distinction testable rather than decorative:
 *
 *   unavailable  the profile module is not active - WE COULD NOT LOOK
 *   empty        the module is active and the profile is untouched
 *   partial      answered, but not fully; observed/expected carry the count
 *   ok           every exposed question answered
 *
 * The first two are the pair that matters. A site with no core profile module
 * and a site whose operator has answered nothing are indistinguishable if both
 * report zero, and they call for opposite actions: fix the install, or ask the
 * operator some questions.
 *
 * @package PublisherKits\Core
 * @since   0.9.11
 */

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

if ( ! class_exists( 'PKCORE_Ability_Profile' ) ) {

	/**
	 * Class PKCORE_Ability_Profile
	 *
	 * @since 0.9.11
	 */
	class PKCORE_Ability_Profile {

		/**
		 * The editorial profile keys this ability exposes.
		 *
		 * A CLOSED LIST, not "everything except the commercial ones". The
		 * profile gains keys over time by contract - return arrays may GAIN
		 * keys - so an exclusion list would silently start leaking each new
		 * field the day it was added. An allow-list fails the safe way: a new
		 * commercial field is invisible here until someone decides otherwise.
		 *
		 * @return string[]
		 */
		public static function editorial_keys() {
			return array(
				'description',
				'sector',
				'site_type',
				'audience',
				'audience_readers',
				'topics_declared',
				'exclusions',
				'formats',
				'keywords',
				'house_style',
				'editorial_rules',
				'context',
				'primary_goal',
				'secondary_goals',
			);
		}

		/**
		 * Hook the registration.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function init() {
			add_action( 'pkcore_register_abilities', array( __CLASS__, 'register' ) );
		}

		/**
		 * Register the ability.
		 *
		 * The class_exists() guard is not defensive clutter. pkcore is
		 * newest-wins: the core that boots is the newest bundled across all
		 * active plugins, so a plugin does not necessarily run its own copy.
		 * This file could be loaded by a payload whose PKCORE_Abilities came
		 * from elsewhere, or - during a partial sync - not at all.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function register() {
			if ( ! class_exists( 'PKCORE_Abilities' ) ) {
				return;
			}

			PKCORE_Abilities::register(
				'site-profile',
				array(
					'label'       => __( 'Get the publication profile', 'publisherkits-socialpromoter' ),
					'description' => __( 'What this publication is about, who it is for, and the editorial rules it works to: description, sector, audience, declared topics, exclusions, formats, keywords, house style and goals. Read this before answering any question about the site\'s content or audience. Commercial data (revenue model, targets, buyers) is deliberately NOT included and requires a separate, higher permission.', 'publisherkits-socialpromoter' ),
					'tier'        => 'content',
					'callback'    => array( __CLASS__, 'answer' ),
					'data_schema' => self::data_schema(),
				)
			);
		}

		/**
		 * Answer the ability.
		 *
		 * @since 0.9.11
		 * @param mixed $input Unused; this ability takes no input.
		 * @return array Provenance envelope.
		 */
		public static function answer( $input = null ) {
			unset( $input );

			// WE COULD NOT LOOK - the contract class is absent entirely. Only
			// reachable on a partially-synced payload, but a wrong answer here
			// would be indistinguishable from a real one, so it is checked.
			if ( ! class_exists( 'PKCORE_Profile' ) ) {
				return PKCORE_Abilities::unavailable(
					__( 'The PKCore_Profile contract is not loaded on this site, so no site profile can be read. This is not an empty profile - it is an unreadable one.', 'publisherkits-socialpromoter' ),
					array( 'source' => 'PKCore_Profile contract' )
				);
			}

			// THE BRANCH THAT MATTERS IN PRODUCTION, AND IT IS NOT AN EDGE
			// CASE. class_exists() is NOT the right test: PKCORE_Profile is
			// required unconditionally when the core boots, so it exists even
			// where no plugin asked for the profile module.
			//
			// Measured 27 Aug: Engagement Signals, ES Intelligence and
			// Publisher Tools each request array( 'notify' ) and nothing else,
			// and Content Classifier requests array( 'classify' ). Those are
			// precisely the tranche 1 plugins. On a site running only those,
			// the profile option is readable and empty, there is no Site
			// Profile screen, and nobody has ever been asked a question.
			//
			// Testing class_exists() alone would therefore report "the
			// publisher has answered nothing" on every tranche 1 site - which
			// is the absent-read-as-empty failure this envelope exists to
			// prevent, shipped in the first consumer of the helper that
			// prevents it.
			$module_active = ! class_exists( 'PKCORE_Core' ) || PKCORE_Core::module_active( 'profile' );

			$profile = PKCORE_Profile::all();
			$exposed = array();
			foreach ( self::editorial_keys() as $key ) {
				if ( array_key_exists( $key, $profile ) ) {
					$exposed[ $key ] = $profile[ $key ];
				}
			}

			$expected = count( self::editorial_keys() );
			$observed = 0;
			foreach ( $exposed as $value ) {
				if ( ! self::is_unanswered( $value ) ) {
					++$observed;
				}
			}

			$provenance = array(
				'source'      => 'PKCore_Profile contract v' . PKCORE_Profile::contract_version() . ' (option pkcore_profile)',
				'measured_at' => self::last_updated( $profile ),
			);

			// No profile surface on this site AND nothing stored. WE COULD NOT
			// LOOK: there is no screen where these questions are asked, so
			// "unanswered" would blame an operator who was never offered them.
			if ( ! $module_active && 0 === $observed ) {
				return PKCORE_Abilities::unavailable(
					__( 'No active plugin on this site requests the PublisherKits Core "profile" module, so there is no Site Profile screen and nothing has ever been asked. This is an absent profile, not an unanswered one - do not report it as a publisher who declined to answer. Activate a plugin that requests the profile module (Competitor Intelligence, TagWise, SocialPromoter or EmailBuilder).', 'publisherkits-socialpromoter' ),
					array( 'source' => 'PKCore_Profile contract (option pkcore_profile); PKCORE_Core::module_active( \'profile\' ) is false' )
				);
			}

			// Stored answers exist even though no plugin currently manages the
			// profile - a plugin that once did has been deactivated. The data
			// is real and readable, so return it: declining here would be
			// over-abstention, which costs a real answer and reports nothing
			// while doing it. Flag the staleness instead of hiding the value.
			if ( ! $module_active ) {
				$provenance['note'] = self::add_note( $provenance, __( 'These answers are stored, but no active plugin currently requests the profile module, so there is no screen on which to review or update them. Treat them as potentially stale and check measured_at.', 'publisherkits-socialpromoter' ) );
			}

			// WE LOOKED AND FOUND NOTHING. Genuinely empty: the module is
			// active, the option is readable, and the operator has been asked
			// and answered nothing. Actionable, and completely different from
			// the absent case above.
			if ( 0 === $observed ) {
				$provenance['note'] = self::add_note( $provenance, __( 'The profile exists and is readable; nobody has answered any of its questions yet. Ask the operator to complete PublisherKits > Site Profile rather than inferring the answers.', 'publisherkits-socialpromoter' ) );
				return PKCORE_Abilities::empty_result( array(), $provenance );
			}

			// Answered, but not fully. The denominator travels with the
			// figure: "seven of fourteen" is a measurement, "seven" is not.
			if ( $observed < $expected ) {
				$provenance['note'] = self::add_note( $provenance, __( 'Some profile questions are unanswered. Treat the missing fields as unknown, not as negative answers - an empty "exclusions" does not mean the publication excludes nothing.', 'publisherkits-socialpromoter' ) );
				return PKCORE_Abilities::partial( $exposed, $observed, $expected, $provenance );
			}

			return PKCORE_Abilities::ok( $exposed, $provenance );
		}

		/**
		 * Append a note without discarding one already recorded.
		 *
		 * Assignment would silently drop the earlier caveat. Both can be true
		 * at once - a profile can be stale AND half-answered - and the one
		 * that gets overwritten is the one nobody reads.
		 *
		 * @since 0.9.11
		 * @param array  $provenance Provenance so far.
		 * @param string $note       Note to add.
		 * @return string
		 */
		private static function add_note( $provenance, $note ) {
			$existing = isset( $provenance['note'] ) ? trim( (string) $provenance['note'] ) : '';
			$note     = trim( (string) $note );
			if ( '' === $existing ) {
				return $note;
			}
			if ( '' === $note ) {
				return $existing;
			}
			return $existing . ' ' . $note;
		}

		/**
		 * When the profile was last updated, from its own per-section meta.
		 *
		 * Returns '' when nothing recorded it. NOT the current time: stamping
		 * "now" on a profile that was last true eighteen months ago is exactly
		 * the failure `_meta` was added to prevent.
		 *
		 * @since 0.9.11
		 * @param array $profile Full profile.
		 * @return string
		 */
		private static function last_updated( $profile ) {
			$meta   = isset( $profile['_meta'] ) && is_array( $profile['_meta'] ) ? $profile['_meta'] : array();
			$newest = '';
			foreach ( $meta as $section ) {
				if ( ! is_array( $section ) || empty( $section['updated_at'] ) ) {
					continue;
				}
				$stamp = (string) $section['updated_at'];
				if ( '' === $newest || strtotime( $stamp ) > strtotime( $newest ) ) {
					$newest = $stamp;
				}
			}
			return $newest;
		}

		/**
		 * Whether a profile value counts as unanswered. MUST RECURSE.
		 *
		 * `empty()` is false for any array that HAS KEYS, and every nested
		 * default here has keys - house_style is
		 * array( 'language' => '', 'tone_notes' => '' ). A non-recursing test
		 * therefore counts a completely blank nested field as answered, which
		 * would inflate `observed` and report a blank profile as complete.
		 * This exact shape has now been fixed four times in this codebase
		 * (PKCORE_Legacy_Profile, PKCORE_Consumers, PKCORE_Profile, here).
		 *
		 * @since 0.9.11
		 * @param mixed $value Value.
		 * @return bool
		 */
		private static function is_unanswered( $value ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $leaf ) {
					if ( ! self::is_unanswered( $leaf ) ) {
						return false;
					}
				}
				return true;
			}
			if ( is_int( $value ) || is_float( $value ) ) {
				return ( 0 === (int) $value );
			}
			return ( '' === trim( (string) $value ) );
		}

		/**
		 * JSON Schema for the data member.
		 *
		 * Every field is optional and nothing is `required`: the profile is
		 * answered progressively, and a schema demanding a complete one would
		 * make the partial and empty results - the honest ones - fail
		 * validation.
		 *
		 * @since 0.9.11
		 * @return array
		 */
		private static function data_schema() {
			$string_list = array(
				'type'  => 'array',
				'items' => array( 'type' => 'string' ),
			);

			return array(
				'type'        => 'object',
				'description' => __( 'The editorial half of the publication profile. Absent keys are unanswered questions, not negative answers.', 'publisherkits-socialpromoter' ),
				'properties'  => array(
					'description'      => array(
						'type'        => 'string',
						'description' => __( 'The publication in a paragraph, in the publisher\'s own words.', 'publisherkits-socialpromoter' ),
					),
					'sector'           => array(
						'type'        => 'string',
						'description' => __( 'The sector it publishes in.', 'publisherkits-socialpromoter' ),
					),
					'site_type'        => array(
						'type'        => 'string',
						'description' => __( 'Publication type slug, from a closed vocabulary.', 'publisherkits-socialpromoter' ),
					),
					'audience'         => array(
						'type'        => 'string',
						'description' => __( 'b2b, b2c or mixed.', 'publisherkits-socialpromoter' ),
					),
					'audience_readers' => array(
						'type'        => 'object',
						'description' => __( 'Who reads it: description, sectors, seniority. Distinct from who BUYS from it, which is not exposed here.', 'publisherkits-socialpromoter' ),
					),
					'topics_declared'  => array_merge(
						$string_list,
						array( 'description' => __( 'Topics the publisher SAYS they cover. Not the topics the Classifier measures - the distance between the two is meaningful, so do not treat these as observed coverage.', 'publisherkits-socialpromoter' ) )
					),
					'exclusions'       => array_merge(
						$string_list,
						array( 'description' => __( 'What it explicitly does not cover.', 'publisherkits-socialpromoter' ) )
					),
					'formats'          => array_merge(
						$string_list,
						array( 'description' => __( 'Content formats produced.', 'publisherkits-socialpromoter' ) )
					),
					'keywords'         => array_merge(
						$string_list,
						array( 'description' => __( 'Target keyword vocabulary, stored lower-cased.', 'publisherkits-socialpromoter' ) )
					),
					'house_style'      => array(
						'type'        => 'object',
						'description' => __( 'Publication-wide writing rules: language and tone notes. Per-format rules live in the plugin that produces that format and compose with these.', 'publisherkits-socialpromoter' ),
					),
					'editorial_rules'  => array_merge(
						$string_list,
						array( 'description' => __( 'Rules that apply to everything the publication produces.', 'publisherkits-socialpromoter' ) )
					),
					'context'          => array(
						'type'        => 'string',
						'description' => __( 'Free-text background about the publication.', 'publisherkits-socialpromoter' ),
					),
					'primary_goal'     => array(
						'type'        => 'string',
						'description' => __( 'The publication\'s primary goal slug.', 'publisherkits-socialpromoter' ),
					),
					'secondary_goals'  => array_merge(
						$string_list,
						array( 'description' => __( 'Up to two secondary goal slugs.', 'publisherkits-socialpromoter' ) )
					),
				),
			);
		}
	}
}
