<?php
/**
 * Registry for V2 third-party schema extension contributors.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

/**
 * Collects and normalizes extension contributions with forced source tagging.
 */
class SchemaExtensionRegistry {

	const SOURCE = 'extension';

	/**
	 * Cached contributor instances for the request.
	 *
	 * @var SchemaExtensionContributor[]|null
	 */
	private static $contributors = null;

	/**
	 * Reset request cache (tests).
	 *
	 * @return void
	 */
	public static function reset() {
		self::$contributors = null;
	}

	/**
	 * Registered contributors (filter only; no built-ins).
	 *
	 * Filter: seovaultseo_schema_extension_contributors
	 *
	 * @return SchemaExtensionContributor[]
	 */
	public static function get_contributors() {
		if ( null !== self::$contributors ) {
			return self::$contributors;
		}

		$built_in = array();

		/**
		 * Register schema extension contributors.
		 *
		 * Each item must implement SchemaExtensionContributor.
		 * Malformed/non-interface entries are dropped.
		 *
		 * @param array $contributors Contributor instances.
		 */
		$filtered = apply_filters( 'seovaultseo_schema_extension_contributors', $built_in );
		if ( ! is_array( $filtered ) ) {
			$filtered = $built_in;
		}

		$clean = array();
		foreach ( $filtered as $contributor ) {
			if ( $contributor instanceof SchemaExtensionContributor ) {
				$clean[] = $contributor;
			}
		}

		self::$contributors = $clean;
		return self::$contributors;
	}

	/**
	 * Collect tagged schemas from all contributors.
	 *
	 * Throws from individual contributors are swallowed; other contributors continue.
	 *
	 * @param \WP_Post|\WP_Term|\WP_User|null $object Render object.
	 * @return array<int,array{source:string,source_ref:string,schema:array}>
	 */
	public static function collect_tagged_schemas( $object = null ) {
		$tagged = array();

		foreach ( self::get_contributors() as $contributor ) {
			try {
				$rows = $contributor->collect_tagged_schemas( $object );
			} catch ( \Throwable $e ) {
				unset( $e );
				continue;
			}

			if ( ! is_array( $rows ) ) {
				continue;
			}

			foreach ( $rows as $row ) {
				$normalized = self::normalize_tagged_row( $row, $contributor );
				if ( null !== $normalized ) {
					$tagged[] = $normalized;
				}
			}
		}

		return $tagged;
	}

	/**
	 * Normalize legacy seovaultseo_set_schema filter return into tagged rows.
	 *
	 * Accepts:
	 * - list of schema configs/nodes
	 * - list of already-tagged {source,source_ref,schema} rows (source forced to extension)
	 *
	 * Non-array filter returns yield an empty list (hostile returns rejected).
	 *
	 * @param mixed $filter_return Filter return value.
	 * @return array<int,array{source:string,source_ref:string,schema:array}>
	 */
	public static function normalize_set_schema_return( $filter_return ) {
		if ( ! is_array( $filter_return ) ) {
			return array();
		}

		$tagged = array();
		foreach ( $filter_return as $index => $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			if ( isset( $item['schema'] ) && is_array( $item['schema'] ) ) {
				$normalized = self::normalize_tagged_row(
					array(
						'source'     => isset( $item['source'] ) ? (string) $item['source'] : self::SOURCE,
						'source_ref' => isset( $item['source_ref'] ) ? (string) $item['source_ref'] : ( self::SOURCE . ':' . (int) $index ),
						'schema'     => $item['schema'],
					),
					null,
					(int) $index
				);
			} else {
				$normalized = self::normalize_tagged_row(
					array(
						'source'     => self::SOURCE,
						'source_ref' => self::SOURCE . ':' . (int) $index,
						'schema'     => $item,
					),
					null,
					(int) $index
				);
			}

			if ( null !== $normalized ) {
				$tagged[] = $normalized;
			}
		}

		return $tagged;
	}

	/**
	 * Force extension source metadata. Claiming integration/custom is rejected.
	 *
	 * @param mixed                            $row         Raw row.
	 * @param SchemaExtensionContributor|null  $contributor Contributor or null for legacy filter.
	 * @param int                              $index       Fallback index.
	 * @return array{source:string,source_ref:string,schema:array}|null
	 */
	private static function normalize_tagged_row( $row, $contributor = null, $index = 0 ) {
		if ( ! is_array( $row ) ) {
			return null;
		}

		$schema = null;
		if ( isset( $row['schema'] ) && is_array( $row['schema'] ) ) {
			$schema = $row['schema'];
		} elseif ( ! isset( $row['source'] ) && ! isset( $row['source_ref'] ) ) {
			$schema = $row;
		}

		if ( null === $schema || empty( $schema ) ) {
			return null;
		}

		// Extensions cannot impersonate higher-precedence sources.
		$claimed = isset( $row['source'] ) ? (string) $row['source'] : self::SOURCE;
		if ( '' !== $claimed && self::SOURCE !== $claimed && 'extension' !== $claimed ) {
			// Allow only extension; drop attempts to claim integration/custom/etc.
			return null;
		}

		$extension_id = 'legacy';
		if ( $contributor instanceof SchemaExtensionContributor ) {
			$extension_id = sanitize_key( (string) $contributor->get_extension_id() );
			if ( '' === $extension_id ) {
				$extension_id = 'unknown';
			}
		}

		$ref = isset( $row['source_ref'] ) ? (string) $row['source_ref'] : '';
		$ref = SchemaSourceDescriptor::normalize_source_ref( $ref );
		if ( '' === $ref ) {
			$ref = self::SOURCE . ':' . $extension_id;
			if ( null === $contributor ) {
				$ref = self::SOURCE . ':' . (int) $index;
			}
		} elseif ( 0 !== strpos( $ref, self::SOURCE . ':' ) ) {
			$ref = self::SOURCE . ':' . $extension_id . ':' . ltrim( $ref, ':' );
		}

		return array(
			'source'     => self::SOURCE,
			'source_ref' => $ref,
			'schema'     => $schema,
		);
	}
}
