<?php
/**
 * PK Core - classification provider registry (tier negotiation).
 *
 * Mirrors the AI Client's provider model (PK-CORE-SPEC-00 section 7). The
 * deterministic engine is the permanent tier-10 floor; the full Classifier
 * plugin registers at tier 100 as the AI implementation. Highest tier wins
 * PER REQUEST, not per install: a provider that declines (returns null) or
 * throws is skipped for that batch, falling through to lower tiers and
 * ultimately the floor - a misbehaving AI provider can degrade quality,
 * never availability.
 *
 * @package PublisherKits\Core
 * @since   0.1.0
 */

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

if ( ! class_exists( 'PKCORE_Providers' ) ) {

	/**
	 * Class PKCORE_Providers
	 */
	class PKCORE_Providers {

		/**
		 * The floor provider's id and tier.
		 */
		const FLOOR_ID   = 'pkcore-deterministic';
		const FLOOR_TIER = 10;

		/**
		 * Registered providers, keyed by id.
		 *
		 * @var array<string,array>
		 */
		private static $providers = array();

		/**
		 * Register the built-in deterministic floor. Called by PKCORE_Core;
		 * cannot be unregistered.
		 *
		 * @return void
		 */
		public static function register_floor() {
			self::$providers[ self::FLOOR_ID ] = array(
				'id'             => self::FLOOR_ID,
				'label'          => 'PublisherKits Core (deterministic)',
				'tier'           => self::FLOOR_TIER,
				'semantic'       => false,
				'classify_items' => array( 'PKCORE_Engine', 'classify_items' ),
			);
		}

		/**
		 * Register a classification provider (PK-CORE-SPEC-00 7.1).
		 *
		 * @param array $provider {
		 *     id             string   Unique id, e.g. 'pkcl-ai'.
		 *     label          string   Human label.
		 *     tier           int      Higher answers first; the floor is 10.
		 *     semantic       bool     Meaning-based (not keyword)?
		 *     classify_items callable ($items, $args, $pack) => results map,
		 *                             same shape as the engine; may return
		 *                             null to decline a batch.
		 * }
		 * @return bool True when registered.
		 */
		public static function register_provider( $provider ) {
			if ( ! is_array( $provider ) || empty( $provider['id'] ) || empty( $provider['classify_items'] ) ) {
				return false;
			}
			$id = (string) $provider['id'];
			if ( self::FLOOR_ID === $id ) {
				return false; // The floor is core-owned.
			}
			if ( ! is_callable( $provider['classify_items'] ) ) {
				return false;
			}
			self::$providers[ $id ] = array(
				'id'             => $id,
				'label'          => isset( $provider['label'] ) ? (string) $provider['label'] : $id,
				'tier'           => isset( $provider['tier'] ) ? (int) $provider['tier'] : self::FLOOR_TIER + 1,
				'semantic'       => ! empty( $provider['semantic'] ),
				'classify_items' => $provider['classify_items'],
			);
			return true;
		}

		/**
		 * The active (highest-tier) provider's consumer-facing summary.
		 *
		 * @return array { id, label, tier, semantic }
		 */
		public static function active_info() {
			$active = null;
			foreach ( self::$providers as $provider ) {
				if ( null === $active || $provider['tier'] > $active['tier'] ) {
					$active = $provider;
				}
			}
			if ( null === $active ) {
				// Pre-boot safety: report the floor even if registration has
				// not run yet.
				return array(
					'id'       => self::FLOOR_ID,
					'label'    => 'PublisherKits Core (deterministic)',
					'tier'     => self::FLOOR_TIER,
					'semantic' => false,
				);
			}
			return array(
				'id'       => $active['id'],
				'label'    => $active['label'],
				'tier'     => (int) $active['tier'],
				'semantic' => (bool) $active['semantic'],
			);
		}

		/**
		 * Classify a batch through the tier list with per-item fallback.
		 *
		 * Providers are consulted highest tier first. A provider may decline
		 * a whole batch (null) or leave individual items unanswered; whatever
		 * remains falls through to the next tier. The deterministic floor
		 * answers everything left.
		 *
		 * @param array $items Map id => item array.
		 * @param array $args  Request args (context etc.).
		 * @param array $pack  The site pack.
		 * @return array Map id => { topics, method, confidence }.
		 */
		public static function classify_batch( $items, $args, $pack ) {
			$out = array();
			if ( ! is_array( $items ) || empty( $items ) ) {
				return $out;
			}

			$providers = self::$providers;
			if ( empty( $providers ) ) {
				self::register_floor();
				$providers = self::$providers;
			}
			usort(
				$providers,
				static function ( $a, $b ) {
					return $b['tier'] - $a['tier'];
				}
			);

			$remaining = $items;
			foreach ( $providers as $provider ) {
				if ( empty( $remaining ) ) {
					break;
				}
				try {
					$results = call_user_func( $provider['classify_items'], $remaining, $args, $pack );
				} catch ( \Throwable $e ) {
					$results = null; // A throwing provider degrades quality, never availability.
				}
				if ( ! is_array( $results ) ) {
					continue; // Declined the batch.
				}
				foreach ( $results as $id => $row ) {
					if ( ! isset( $remaining[ $id ] ) || ! is_array( $row ) || ! isset( $row['topics'] ) ) {
						continue;
					}
					$normalised = array(
						'topics'     => array_values( array_map( 'strval', (array) $row['topics'] ) ),
						'method'     => isset( $row['method'] ) ? (string) $row['method'] : ( $provider['semantic'] ? 'ai' : 'deterministic' ),
						'confidence' => isset( $row['confidence'] ) && null !== $row['confidence'] ? (float) $row['confidence'] : null,
					);
					// Additive contract (spec 11.2): result rows may GAIN keys.
					// A provider can supply extras beyond the three normalised
					// keys (e.g. the full Classifier's content_type) and they
					// pass through; consumers use what they recognise.
					$out[ $id ] = $normalised + $row;
					unset( $remaining[ $id ] );
				}
			}

			// Anything still unanswered (floor missing or filtered) is 'none'.
			foreach ( $remaining as $id => $item ) {
				$out[ $id ] = array(
					'topics'     => array(),
					'method'     => 'none',
					'confidence' => null,
				);
			}
			return $out;
		}
	}
}
