<?php
/**
 * Canonical field registry and ownership resolver.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Field;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals -- scheduled_changes_ is the stable plugin prefix.

use ScheduledChanges\Provider\FieldProvider;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Provider\ProviderDiscoveryCache;
use ScheduledChanges\Provider\ProviderRegistry;

/**
 * Discovers fields and resolves exactly one canonical owner for each path.
 */
final class FieldRegistry {
	private ProviderRegistry $providers;
	private ProviderDiscoveryCache $cache;
	/**
	 * @var OwnershipCollision[]
	 */
	private array $lastCollisions = array();

	public function __construct( ProviderRegistry $providers, ProviderDiscoveryCache $cache ) {
		$this->providers = $providers;
		$this->cache     = $cache;
	}

	public function discover( ObjectContext $context ): FieldCatalog {
		$claims = array();

		foreach ( $this->providers->all() as $provider ) {
			if ( ! $provider->supports( $context ) ) {
				continue;
			}

			$fields = $this->cache->remember(
				$provider,
				$context,
				static fn (): FieldCollection => $provider->discover( $context )
			);

			foreach ( $fields as $field ) {
				if ( $field->provider() !== $provider->id() ) {
					throw new \RuntimeException(
						sprintf(
							'Field %1$s declares provider %2$s but was discovered by %3$s.',
							$field->id(),
							$field->provider(),
							$provider->id()
						)
					);
				}

				$claims[ $field->id() ][] = array(
					'field'    => $field,
					'provider' => $provider,
				);
			}
		}

		$fields     = new FieldCollection();
		$owners     = array();
		$collisions = array();

		ksort( $claims );

		foreach ( $claims as $fieldId => $fieldClaims ) {
			usort(
				$fieldClaims,
				static function ( array $left, array $right ): int {
					/** @var FieldProvider $leftProvider */
					$leftProvider = $left['provider'];
					/** @var FieldProvider $rightProvider */
					$rightProvider = $right['provider'];

					$priority = \ScheduledChanges\Provider\ProviderCategory::priority( $rightProvider->category() ) <=> \ScheduledChanges\Provider\ProviderCategory::priority( $leftProvider->category() );

					return 0 !== $priority
						? $priority
						: strcmp( $leftProvider->id(), $rightProvider->id() );
				}
			);

			$selected = $fieldClaims[0];
			/** @var FieldDefinition $selectedField */
			$selectedField = $selected['field'];
			/** @var FieldProvider $selectedProvider */
			$selectedProvider = $selected['provider'];
			$fields->add( $selectedField );
			$owners[ $fieldId ] = $selectedProvider;

			$writers = array_values(
				array_filter(
					$fieldClaims,
					static fn ( array $claim ): bool => $claim['field']->writable()
				)
			);

			if ( count( $writers ) > 1 ) {
				/** @var FieldProvider $firstWriter */
				$firstWriter = $writers[0]['provider'];
				/** @var FieldProvider $secondWriter */
				$secondWriter = $writers[1]['provider'];
				$collision    = new OwnershipCollision(
					$fieldId,
					array_map(
						static fn ( array $claim ): string => $claim['provider']->id(),
						$writers
					),
					$firstWriter->id(),
					\ScheduledChanges\Provider\ProviderCategory::priority( $firstWriter->category() ) === \ScheduledChanges\Provider\ProviderCategory::priority( $secondWriter->category() )
				);
				$collisions[] = $collision;

				/**
				 * Fires when more than one provider claims a canonical write path.
				 *
				 * @param OwnershipCollision $collision Collision details.
				 */
				if ( function_exists( 'do_action' ) ) {
					do_action( 'scheduled_changes_field_ownership_collision', $collision );
				}
			}
		}

		$this->lastCollisions = $collisions;

		return new FieldCatalog( $fields, $owners, $collisions );
	}

	/**
	 * @return OwnershipCollision[]
	 */
	public function lastCollisions(): array {
		return $this->lastCollisions;
	}

	public function invalidateCache(): void {
		$this->cache->invalidate();
	}
}
