<?php
/**
 * PK Core - CLASSIFIER contract facade (PKCORE_Classify).
 *
 * The single read/classify surface every consumer uses (PK-CORE-SPEC-00
 * section 6). Consumers never touch pkcore tables, the engine or the provider
 * registry directly; the full Classifier plugs in UNDERNEATH this surface and
 * consumers cannot tell whether results are deterministic or AI.
 *
 * CONTRACT STABILITY: method names, argument shapes and returned key names
 * are stable from v1 and only ever grow additively (spec section 11). Every
 * method is safe to call the moment the class exists - empty structures
 * before any pack exists, never errors.
 *
 * Two item universes, deliberately separate (spec 6.0):
 *   SITE content (WP posts)  -> classified AND STORED by the core
 *                               (topics_for_objects / ensure_classified).
 *   EXTERNAL items           -> classified on request via classify_items();
 *                               the CALLER stores results in its own tables.
 *
 * @package PublisherKits\Core
 * @since   0.1.0
 */

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

if ( ! class_exists( 'PKCORE_Classify' ) ) {

	/**
	 * Class PKCORE_Classify
	 */
	class PKCORE_Classify {

		/**
		 * Contract version. Increments only when methods or keys are ADDED.
		 *
		 * @return int
		 */
		public static function contract_version() {
			return 1;
		}

		/**
		 * The site pack as a consumer-facing structure (spec 6.1).
		 *
		 * @param array $args { status: 'active' (default) | 'all' }.
		 * @return array { revision:int, generated_at:string, topics:array[],
		 *                 content_types:string[], aliases:array }
		 */
		public static function get_pack( $args = array() ) {
			$status = ( isset( $args['status'] ) && 'all' === $args['status'] ) ? 'all' : 'active';
			$pack   = PKCORE_Pack::get();
			return array(
				'revision'      => (int) $pack['revision'],
				'generated_at'  => (string) $pack['generated_at'],
				'topics'        => PKCORE_Pack::topics( $status ),
				'content_types' => array_values( (array) $pack['content_types'] ),
				'aliases'       => (array) $pack['aliases'],
			);
		}

		/**
		 * The active classification provider's summary (spec 6.1).
		 *
		 * @return array { id:string, label:string, tier:int, semantic:bool }
		 */
		public static function provider_info() {
			return PKCORE_Providers::active_info();
		}

		/**
		 * THE aggregatable batch classify - "topics for these N items"
		 * (spec 6.2). External or ad-hoc items; results are NOT stored by
		 * the core (the caller owns storage for its own corpus).
		 *
		 * @param array $items Map caller-id => { title:string (required),
		 *                     terms?:string[], text?:string, url?:string }.
		 * @param array $args  { context: 'external' (default) | 'site' }.
		 * @return array Map caller-id => { topics:string[], method:string,
		 *               confidence:float|null }.
		 */
		public static function classify_items( $items, $args = array() ) {
			if ( ! is_array( $items ) || empty( $items ) ) {
				return array();
			}
			$args            = is_array( $args ) ? $args : array();
			$args['context'] = ( isset( $args['context'] ) && 'site' === $args['context'] ) ? 'site' : 'external';

			$clean = array();
			foreach ( $items as $id => $item ) {
				if ( ! is_array( $item ) || ! isset( $item['title'] ) ) {
					continue; // title is required per contract.
				}
				$clean[ $id ] = $item;
			}
			if ( empty( $clean ) ) {
				return array();
			}
			return PKCORE_Providers::classify_batch( $clean, $args, PKCORE_Pack::get() );
		}

		/**
		 * Stored classifications for site content (spec 6.3). Reads storage
		 * only; ids never classified are absent from the returned map.
		 *
		 * @param array $object_ids Object ids.
		 * @param array $args       { object_type: 'post' (default) }.
		 * @return array Map object_id => { topics:string[], method:string,
		 *               content_type:string, classified_at:string,
		 *               pack_revision:int }.
		 */
		public static function topics_for_objects( $object_ids, $args = array() ) {
			$out = array();
			$ids = self::clean_ids( $object_ids );
			if ( empty( $ids ) || ! class_exists( 'PKCORE_Schema' ) ) {
				return $out;
			}
			$object_type = self::object_type( $args );

			global $wpdb;
			$table        = PKCORE_Schema::table_classifications();
			$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
			$params       = array_merge( array( $object_type ), $ids );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core-owned table name; placeholder list built from count; values prepared.
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT object_id, topics, method, content_type, pack_revision, classified_at FROM {$table} WHERE object_type = %s AND object_id IN ( {$placeholders} )",
					$params
				),
				ARRAY_A
			);
			if ( ! is_array( $rows ) ) {
				return $out;
			}
			foreach ( $rows as $row ) {
				$topics = json_decode( (string) $row['topics'], true );
				if ( ! is_array( $topics ) ) {
					$topics = array();
				}
				// Alias-resolve on read so merges reflect everywhere at once.
				$resolved = array();
				foreach ( $topics as $slug ) {
					$resolved[] = PKCORE_Pack::resolve_alias( (string) $slug );
				}
				$out[ (int) $row['object_id'] ] = array(
					'topics'        => array_values( array_unique( $resolved ) ),
					'method'        => (string) $row['method'],
					'content_type'  => null === $row['content_type'] ? '' : (string) $row['content_type'],
					'classified_at' => (string) $row['classified_at'],
					'pack_revision' => (int) $row['pack_revision'],
				);
			}
			return $out;
		}

		/**
		 * Stored classifications, classifying-and-storing first any id with
		 * no row or a stale row (spec 6.3/8.6). The call a channel plugin
		 * makes before an analytics roll-up.
		 *
		 * @param array $object_ids Object ids.
		 * @param array $args       { object_type: 'post' (default) }.
		 * @return array Same shape as topics_for_objects().
		 */
		public static function ensure_classified( $object_ids, $args = array() ) {
			$ids = self::clean_ids( $object_ids );
			if ( empty( $ids ) ) {
				return array();
			}
			$object_type = self::object_type( $args );
			$stored      = self::topics_for_objects( $ids, $args );
			$watermark   = PKCORE_Pack::structural_revision();

			$todo = array();
			foreach ( $ids as $id ) {
				if ( ! isset( $stored[ $id ] ) || $stored[ $id ]['pack_revision'] < $watermark ) {
					$todo[] = $id;
				}
			}
			if ( empty( $todo ) ) {
				return $stored;
			}

			$items = self::items_from_posts( $todo, $object_type );
			if ( ! empty( $items ) ) {
				$results = PKCORE_Providers::classify_batch(
					$items,
					array( 'context' => 'site' ),
					PKCORE_Pack::get()
				);
				self::store_results( $results, $items, $object_type );
			}

			return self::topics_for_objects( $ids, $args );
		}

		/**
		 * Aggregate any per-object metric by topic (spec 6.4). The caller
		 * owns the metric (clicks, opens, pageviews); this maps it onto the
		 * topic axis using stored classifications. With ring 'core' only
		 * core-ring topics are counted (related and peripheral are holding
		 * buckets, not analysis axes).
		 *
		 * @param array $values_by_object Map object_id => numeric value.
		 * @param array $args             { object_type:'post',
		 *                                  ring:'core' (default) | null }.
		 * @return array Map topic_slug => { value_full:float (full metric
		 *               credited to each counted topic - yield within a
		 *               topic), value_split:float (metric divided equally
		 *               across counted topics - shares, no double count),
		 *               posts:int }.
		 */
		public static function aggregate_by_topic( $values_by_object, $args = array() ) {
			$out = array();
			if ( empty( $values_by_object ) || ! is_array( $values_by_object ) ) {
				return $out;
			}
			$ring = array_key_exists( 'ring', (array) $args ) ? $args['ring'] : 'core';

			$core = array();
			if ( 'core' === $ring ) {
				foreach ( PKCORE_Pack::ring_map() as $slug => $r ) {
					if ( 'core' === $r ) {
						$core[ $slug ] = true;
					}
				}
			}
			$core_only = ! empty( $core );

			$classified = self::topics_for_objects( array_keys( $values_by_object ), $args );
			foreach ( $classified as $object_id => $row ) {
				if ( ! isset( $values_by_object[ $object_id ] ) ) {
					continue;
				}
				$value  = (float) $values_by_object[ $object_id ];
				$topics = $row['topics'];
				if ( $core_only ) {
					$keep = array();
					foreach ( $topics as $slug ) {
						if ( isset( $core[ $slug ] ) ) {
							$keep[] = $slug;
						}
					}
					$topics = $keep;
				}
				$count = count( $topics );
				if ( 0 === $count ) {
					continue;
				}
				$split = $value / $count;
				foreach ( $topics as $slug ) {
					if ( ! isset( $out[ $slug ] ) ) {
						$out[ $slug ] = array(
							'value_full'  => 0.0,
							'value_split' => 0.0,
							'posts'       => 0,
						);
					}
					$out[ $slug ]['value_full']  += $value;
					$out[ $slug ]['value_split'] += $split;
					$out[ $slug ]['posts']++;
				}
			}
			return $out;
		}

		/* ------------------------------------------------------------------
		 * Provider registration passthrough (spec 7.1)
		 * ---------------------------------------------------------------- */

		/**
		 * Register a higher-tier classification provider. See
		 * PKCORE_Providers::register_provider() for the shape.
		 *
		 * @param array $provider Provider definition.
		 * @return bool
		 */
		public static function register_provider( $provider ) {
			return PKCORE_Providers::register_provider( $provider );
		}

		/* ------------------------------------------------------------------
		 * Internals
		 * ---------------------------------------------------------------- */

		/**
		 * Positive-int id list.
		 *
		 * @param mixed $object_ids Raw ids.
		 * @return int[]
		 */
		private static function clean_ids( $object_ids ) {
			$ids = array();
			foreach ( (array) $object_ids as $id ) {
				$id = (int) $id;
				if ( $id > 0 ) {
					$ids[] = $id;
				}
			}
			return array_values( array_unique( $ids ) );
		}

		/**
		 * The object_type arg, defaulted.
		 *
		 * @param array $args Request args.
		 * @return string
		 */
		private static function object_type( $args ) {
			return ! empty( $args['object_type'] ) ? (string) $args['object_type'] : 'post';
		}

		/**
		 * Build classify items from WP posts: title, term names, bounded text.
		 *
		 * @param int[]  $ids         Post ids.
		 * @param string $object_type Post type.
		 * @return array Map id => item.
		 */
		private static function items_from_posts( $ids, $object_type ) {
			$items = array();
			foreach ( $ids as $id ) {
				$post = get_post( $id );
				if ( ! $post || $post->post_type !== $object_type ) {
					continue;
				}
				$terms = array();
				foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
					$names = get_the_terms( $id, $taxonomy );
					if ( is_array( $names ) ) {
						foreach ( $names as $term ) {
							$terms[] = $term->name;
						}
					}
				}
				$text = '' !== $post->post_excerpt
					? $post->post_excerpt
					: wp_strip_all_tags( (string) $post->post_content );

				$items[ $id ] = array(
					'title'         => get_the_title( $post ),
					'terms'         => $terms,
					'text'          => substr( $text, 0, 5000 ),
					'_published_at' => $post->post_date_gmt, // Internal, for storage.
				);
			}
			return $items;
		}

		/**
		 * Upsert classification rows for site content.
		 *
		 * @param array  $results     Map id => { topics, method, confidence }.
		 * @param array  $items       The classified items (for published_at).
		 * @param string $object_type Post type.
		 * @return void
		 */
		private static function store_results( $results, $items, $object_type ) {
			if ( ! class_exists( 'PKCORE_Schema' ) || empty( $results ) ) {
				return;
			}
			global $wpdb;
			$table    = PKCORE_Schema::table_classifications();
			$revision = PKCORE_Pack::revision();
			$now      = current_time( 'mysql', true );
			$stored   = array();

			foreach ( $results as $id => $row ) {
				$published = isset( $items[ $id ]['_published_at'] ) ? (string) $items[ $id ]['_published_at'] : '';
				if ( '0000-00-00 00:00:00' === $published ) {
					$published = '';
				}
				// prepare() cannot emit SQL NULL through %s, so the
				// published_at placeholder is branched.
				$published_sql = '' === $published ? 'NULL' : '%s';
				$params        = array(
					$object_type,
					(int) $id,
					wp_json_encode( array_values( (array) $row['topics'] ) ),
					(string) $row['method'],
					$revision,
				);
				if ( '' !== $published ) {
					$params[] = $published;
				}
				$params[] = $now;
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core-owned table; values prepared.
				$wpdb->query(
					$wpdb->prepare(
						"INSERT INTO {$table} (object_type, object_id, topics, method, content_type, pack_revision, published_at, classified_at)
						 VALUES (%s, %d, %s, %s, NULL, %d, {$published_sql}, %s)
						 ON DUPLICATE KEY UPDATE topics = VALUES(topics), method = VALUES(method), pack_revision = VALUES(pack_revision), published_at = VALUES(published_at), classified_at = VALUES(classified_at)",
						$params
					)
				);
				$stored[] = (int) $id;
			}

			if ( ! empty( $stored ) ) {
				/**
				 * Fires when stored site-content classifications change.
				 *
				 * @since 0.1.0
				 * @param string $object_type The object type.
				 * @param int[]  $object_ids  The (re)classified object ids.
				 */
				do_action( 'pkcore_classifications_updated', $object_type, $stored );
			}
		}
	}
}
