<?php

namespace Fotogenic_AI;

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

/**
 * Strictly normalize the universal browser draft without dispatching work.
 */
final class Workflow_Draft_Normalizer {
	private const TOP_LEVEL_FIELDS = [
		'schemaVersion',
		'workflow',
		'sourceType',
		'scope',
		'selection',
		'sourcePolicy',
		'recipe',
		'resultPolicy',
	];

	private const SELECTION_FIELDS = [ 'strategy', 'selectedIds', 'excludedIds', 'query' ];
	private const SOURCE_POLICY_FIELDS = [ 'attachmentIds', 'sourceMode', 'groups' ];
	private const RESULT_POLICY_FIELDS = [ 'assignmentMode' ];
	private const RECIPE_SNAPSHOT_FIELDS = [ 'schemaVersion', 'workflow', 'sourceType', 'scope', 'recipe' ];
	private const MAX_SOURCE_ATTACHMENTS = 10;

	/**
	 * Normalize one decoded JSON draft.
	 *
	 * Objects remain stdClass values until this method validates their shape, so
	 * an empty JSON array cannot be mistaken for an empty JSON object.
	 *
	 * @param mixed $raw_draft Decoded JSON or trusted internal draft.
	 * @return array|\WP_Error
	 */
	public function normalize( $raw_draft ) {
		$allowed = Workflow_Contract_Guard::reject_forbidden( $raw_draft );
		if ( is_wp_error( $allowed ) ) {
			return $allowed;
		}

		$draft = $this->object_members( $raw_draft, 'draft' );
		if ( is_wp_error( $draft ) ) {
			return $draft;
		}
		$shape = $this->validate_exact_fields( $draft, self::TOP_LEVEL_FIELDS, self::TOP_LEVEL_FIELDS, 'draft' );
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}

		if ( ! is_int( $draft['schemaVersion'] ) || Workflow_Registry::SCHEMA_VERSION !== $draft['schemaVersion'] ) {
			return $this->invalid( __( 'The Studio draft schema version is not supported.', 'fotogenic-ai' ) );
		}
		foreach ( [ 'workflow', 'sourceType', 'scope' ] as $field ) {
			if ( ! is_string( $draft[ $field ] ) || '' === $draft[ $field ] ) {
				return $this->type_error( $field );
			}
		}

		$workflow  = $draft['workflow'];
		$source    = $draft['sourceType'];
		$scope     = $draft['scope'];
		$definition = Workflow_Registry::get( $workflow );
		if ( null === $definition || ! $definition->supports( $source, $scope ) ) {
			return new \WP_Error(
				'fotogenic_ai_unsupported_workflow_source',
				__( 'This workflow, source, and mode combination is not supported.', 'fotogenic-ai' ),
				[ 'status' => 400 ]
			);
		}

		$selection = $this->normalize_selection( $draft['selection'], $scope );
		if ( is_wp_error( $selection ) ) {
			return $selection;
		}
		$recipe = $this->normalize_schema_object(
			$draft['recipe'],
			$definition->get_recipe_schema(),
			'recipe',
			$source,
			$scope
		);
		if ( is_wp_error( $recipe ) ) {
			return $recipe;
		}
		$person_reference = $this->validate_generate_person_reference( $workflow, $recipe );
		if ( is_wp_error( $person_reference ) ) {
			return $person_reference;
		}
		$source_policy = $this->normalize_source_policy( $draft['sourcePolicy'], $workflow, $source, $scope, $definition );
		if ( is_wp_error( $source_policy ) ) {
			return $source_policy;
		}
		$groups_valid = $this->validate_campaign_group_selection( $selection, $source_policy, $workflow, $source, $scope );
		if ( is_wp_error( $groups_valid ) ) {
			return $groups_valid;
		}
		if ( 'campaign_set' === $workflow && ! empty( $source_policy['groups'][0] ) ) {
			$recipe        = (array) $source_policy['groups'][0]['recipe'];
			$result_policy = (array) $source_policy['groups'][0]['resultPolicy'];
		} else {
			$result_policy = $this->normalize_result_policy( $draft['resultPolicy'], $workflow, $source );
			if ( is_wp_error( $result_policy ) ) {
				return $result_policy;
			}
		}

		$normalized = [
			'schemaVersion' => Workflow_Registry::SCHEMA_VERSION,
			'workflow'      => $workflow,
			'sourceType'    => $source,
			'scope'         => $scope,
			'selection'     => $selection,
			'sourcePolicy'  => $source_policy,
			'recipe'        => $recipe,
			'resultPolicy'  => $result_policy,
		];

		return Workflow_Contract_Guard::finalize( $normalized );
	}

	/**
	 * Normalize a durable preset without accepting source or result state.
	 *
	 * @param mixed $raw_snapshot JSON-authentic recipe snapshot object.
	 * @return array|\WP_Error
	 */
	public function normalize_recipe_snapshot( $raw_snapshot ) {
		$allowed = Workflow_Contract_Guard::reject_forbidden( $raw_snapshot );
		if ( is_wp_error( $allowed ) ) {
			return $allowed;
		}

		$snapshot = $this->object_members( $raw_snapshot, 'preset' );
		if ( is_wp_error( $snapshot ) ) {
			return $snapshot;
		}
		$shape = $this->validate_exact_fields(
			$snapshot,
			self::RECIPE_SNAPSHOT_FIELDS,
			self::RECIPE_SNAPSHOT_FIELDS,
			'preset'
		);
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}
		if ( ! is_int( $snapshot['schemaVersion'] ) || Workflow_Registry::SCHEMA_VERSION !== $snapshot['schemaVersion'] ) {
			return $this->invalid( __( 'The preset schema version is not supported.', 'fotogenic-ai' ) );
		}
		foreach ( [ 'workflow', 'sourceType', 'scope' ] as $field ) {
			if ( ! is_string( $snapshot[ $field ] ) || '' === $snapshot[ $field ] ) {
				return $this->type_error( $field );
			}
		}

		$workflow   = $snapshot['workflow'];
		$source     = $snapshot['sourceType'];
		$scope      = $snapshot['scope'];
		$definition = Workflow_Registry::get( $workflow );
		if ( null === $definition || ! $definition->supports( $source, $scope ) ) {
			return new \WP_Error(
				'fotogenic_ai_incompatible_preset',
				__( 'This preset is not compatible with the selected workflow, source, and mode.', 'fotogenic-ai' ),
				[ 'status' => 409 ]
			);
		}

		$recipe = $this->normalize_schema_object(
			$snapshot['recipe'],
			$definition->get_recipe_schema(),
			'preset.recipe',
			$source,
			$scope
		);
		if ( is_wp_error( $recipe ) ) {
			return $recipe;
		}
		$person_reference = $this->validate_generate_person_reference( $workflow, $recipe );
		if ( is_wp_error( $person_reference ) ) {
			return $person_reference;
		}

		return Workflow_Contract_Guard::finalize(
			[
				'schemaVersion' => Workflow_Registry::SCHEMA_VERSION,
				'workflow'      => $workflow,
				'sourceType'    => $source,
				'scope'         => $scope,
				'recipe'        => $recipe,
			]
		);
	}

	/**
	 * Keep Automatic, No person, and Use a model distinct at the server boundary.
	 *
	 * An omitted value is Automatic, explicit false is No person, and explicit
	 * true means Use a model. Only the last state requires the Generate subject
	 * reference. Campaign owns a separate optional-reference contract.
	 *
	 * @return true|\WP_Error
	 */
	private function validate_generate_person_reference( string $workflow, array $recipe ) {
		if ( 'product_images' !== $workflow || true !== ( $recipe['personPresence'] ?? null ) ) {
			return true;
		}
		if ( is_int( $recipe['subjectImageId'] ?? null ) && $recipe['subjectImageId'] > 0 ) {
			return true;
		}

		return $this->invalid( __( 'Use a model requires a model reference.', 'fotogenic-ai' ) );
	}

	/**
	 * Hash a normalized draft without retaining or returning its private text.
	 *
	 * @return string|\WP_Error
	 */
	public function fingerprint( array $normalized_draft ) {
		$valid     = true;
		$canonical = $this->canonicalize( $normalized_draft, $valid );
		if ( ! $valid ) {
			return $this->invalid( __( 'The Studio draft could not be fingerprinted.', 'fotogenic-ai' ) );
		}

		$encoded = wp_json_encode( $canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		if ( ! is_string( $encoded ) || '' === $encoded ) {
			return new \WP_Error(
				'fotogenic_ai_draft_fingerprint_failed',
				__( 'The Studio draft could not be fingerprinted.', 'fotogenic-ai' ),
				[ 'status' => 500 ]
			);
		}

		return hash( 'sha256', $encoded );
	}

	/**
	 * Restore strict JSON object positions in a normalized internal draft.
	 *
	 * Normalized empty objects are represented as empty PHP arrays. This helper
	 * is the only supported bridge back to the stdClass/list shape expected by
	 * normalize() when trusted internal drafts require fresh revalidation.
	 *
	 * @return \stdClass|\WP_Error
	 */
	public function rehydrate_normalized( array $draft ) {
		if ( fotogenic_ai_array_is_list( $draft ) ) {
			return $this->type_error( 'draft' );
		}

		$workflow = is_string( $draft['workflow'] ?? null ) ? $draft['workflow'] : '';
		$definition = Workflow_Registry::get( $workflow );
		$selection = is_array( $draft['selection'] ?? null ) ? $draft['selection'] : null;
		$source_policy = is_array( $draft['sourcePolicy'] ?? null ) ? $draft['sourcePolicy'] : null;
		$result_policy = is_array( $draft['resultPolicy'] ?? null ) ? $draft['resultPolicy'] : null;
		$recipe = is_array( $draft['recipe'] ?? null ) ? $draft['recipe'] : null;
		if (
			null === $definition
			|| null === $selection
			|| null === $source_policy
			|| null === $result_policy
			|| null === $recipe
			|| ! is_array( $selection['query'] ?? null )
		) {
			return $this->type_error( 'draft' );
		}

		$recipe_object = $this->rehydrate_schema_object( $recipe, $definition->get_recipe_schema() );
		if ( is_wp_error( $recipe_object ) ) {
			return $recipe_object;
		}

		$draft['selection'] = (object) array_merge(
			$selection,
			[ 'query' => (object) $selection['query'] ]
		);
		if ( isset( $source_policy['groups'] ) && is_array( $source_policy['groups'] ) ) {
			foreach ( $source_policy['groups'] as $group ) {
				if ( ! is_array( $group ) || fotogenic_ai_array_is_list( $group ) ) {
					return $this->type_error( 'sourcePolicy.groups' );
				}
			}
			$source_policy['groups'] = array_map(
				function ( array $group ) use ( $definition ) {
					$group['target'] = (object) ( $group['target'] ?? [] );
					$group_recipe = $this->rehydrate_schema_object(
						(array) ( $group['recipe'] ?? [] ),
						$definition->get_recipe_schema()
					);
					if ( is_wp_error( $group_recipe ) ) {
						return $group_recipe;
					}
					$group['recipe'] = $group_recipe;
					$group['resultPolicy'] = (object) ( $group['resultPolicy'] ?? [] );
					return (object) $group;
				},
				$source_policy['groups']
			);
			foreach ( $source_policy['groups'] as $group ) {
				if ( is_wp_error( $group ) ) {
					return $group;
				}
			}
		}
		$draft['sourcePolicy'] = (object) $source_policy;
		$draft['recipe'] = $recipe_object;
		$draft['resultPolicy'] = (object) $result_policy;

		return (object) $draft;
	}

	/**
	 * @param mixed  $raw   Raw selection object.
	 * @param string $scope Normalized scope.
	 * @return array|\WP_Error
	 */
	private function normalize_selection( $raw, string $scope ) {
		$selection = $this->object_members( $raw, 'selection' );
		if ( is_wp_error( $selection ) ) {
			return $selection;
		}
		$shape = $this->validate_exact_fields( $selection, self::SELECTION_FIELDS, self::SELECTION_FIELDS, 'selection' );
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}
		if ( ! is_string( $selection['strategy'] ) || ! in_array( $selection['strategy'], [ 'explicit', 'query' ], true ) ) {
			return $this->invalid( __( 'Select an explicit or query-backed source strategy.', 'fotogenic-ai' ) );
		}

		$selected = $this->normalize_id_list( $selection['selectedIds'], 'selection.selectedIds' );
		if ( is_wp_error( $selected ) ) {
			return $selected;
		}
		$excluded = $this->normalize_id_list( $selection['excludedIds'], 'selection.excludedIds' );
		if ( is_wp_error( $excluded ) ) {
			return $excluded;
		}
		$query = $this->object_members( $selection['query'], 'selection.query' );
		if ( is_wp_error( $query ) ) {
			return $query;
		}

		if ( 'explicit' === $selection['strategy'] ) {
			if ( empty( $selected ) || ! empty( $excluded ) || ! empty( $query ) ) {
				return $this->invalid( __( 'Explicit selection requires selected IDs and no query exclusions.', 'fotogenic-ai' ) );
			}
		} elseif ( 'bulk' !== $scope ) {
			return $this->invalid( __( 'Query selection is available only for Bulk mode.', 'fotogenic-ai' ) );
		}

		if ( ! empty( array_intersect( $selected, $excluded ) ) ) {
			return $this->invalid( __( 'A source cannot be both selected and excluded.', 'fotogenic-ai' ) );
		}

		return [
			'strategy'    => $selection['strategy'],
			'selectedIds' => $selected,
			'excludedIds' => $excluded,
			// Query fields remain untrusted until the Bulk snapshot owner integrates them.
			'query'       => 'query' === $selection['strategy'] ? $query : [],
		];
	}

	/**
	 * @param mixed                         $raw        Raw source policy object.
	 * @param string                        $source     Normalized source type.
	 * @param string                        $scope      Normalized scope.
	 * @param Workflow_Definition_Interface $definition Workflow definition.
	 * @return array|\WP_Error
	 */
	private function normalize_source_policy(
		$raw,
		string $workflow,
		string $source,
		string $scope,
		Workflow_Definition_Interface $definition
	) {
		$policy = $this->object_members( $raw, 'sourcePolicy' );
		if ( is_wp_error( $policy ) ) {
			return $policy;
		}
		$shape = $this->validate_exact_fields( $policy, self::SOURCE_POLICY_FIELDS, [], 'sourcePolicy' );
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}

		if ( array_key_exists( 'groups', $policy ) ) {
			if ( 'campaign_set' !== $workflow || array_key_exists( 'attachmentIds', $policy ) || array_key_exists( 'sourceMode', $policy ) ) {
				return $this->invalid( __( 'Campaign groups are available only for Campaign Set sources.', 'fotogenic-ai' ) );
			}
			return $this->normalize_campaign_groups( $policy['groups'], $source, $scope, $definition );
		}
		if ( 'campaign_set' === $workflow ) {
			return $this->invalid( __( 'Campaign Set requires immutable source groups.', 'fotogenic-ai' ) );
		}

		$attachment_ids = [];
		if ( array_key_exists( 'attachmentIds', $policy ) ) {
			$attachment_ids = $this->normalize_id_list( $policy['attachmentIds'], 'sourcePolicy.attachmentIds' );
			if ( is_wp_error( $attachment_ids ) ) {
				return $attachment_ids;
			}
			if ( count( $attachment_ids ) > self::MAX_SOURCE_ATTACHMENTS ) {
				return $this->invalid( __( 'Select no more than 10 source attachments.', 'fotogenic-ai' ) );
			}
		}

		$source_mode = null;
		if ( array_key_exists( 'sourceMode', $policy ) ) {
			if ( ! is_string( $policy['sourceMode'] ) ) {
				return $this->type_error( 'sourcePolicy.sourceMode' );
			}
			$source_mode = $policy['sourceMode'];
		}
		if ( null !== $source_mode && 'product_images' === $workflow && 'product' === $source && 'single' === $scope ) {
			if ( ! empty( $attachment_ids ) ) {
				return $this->invalid( __( 'Choose either a Product image source mode or exact source attachments.', 'fotogenic-ai' ) );
			}
			$sources       = $definition->get_sources();
			$allowed_modes = $sources[ $source ]['sourcePolicy']['bulkModes'] ?? [];
			if ( ! in_array( $source_mode, $allowed_modes, true ) ) {
				return $this->invalid( __( 'Select a supported Product image source mode.', 'fotogenic-ai' ) );
			}

			return [ 'sourceMode' => $source_mode ];
		}

		if ( 'bulk' === $scope ) {
			if ( ! empty( $attachment_ids ) ) {
				return $this->invalid( __( 'Bulk source attachments must be resolved from each immutable subject snapshot.', 'fotogenic-ai' ) );
			}
			if ( 'product' !== $source ) {
				return null === $source_mode
					? []
					: $this->invalid( __( 'This Bulk source does not accept a Product image mode.', 'fotogenic-ai' ) );
			}

			$sources       = $definition->get_sources();
			$allowed_modes = $sources[ $source ]['sourcePolicy']['bulkModes'] ?? [];
			if ( null === $source_mode || ! in_array( $source_mode, $allowed_modes, true ) ) {
				return $this->invalid( __( 'Select a supported Product image source mode.', 'fotogenic-ai' ) );
			}

			return [ 'sourceMode' => $source_mode ];
		}

		if ( null !== $source_mode ) {
			return $this->invalid( __( 'Product image source modes are available only in Bulk mode.', 'fotogenic-ai' ) );
		}
		if ( 'product' === $source && empty( $attachment_ids ) ) {
			return $this->invalid( __( 'Choose at least one Product source image.', 'fotogenic-ai' ) );
		}

		return empty( $attachment_ids ) ? [] : [ 'attachmentIds' => $attachment_ids ];
	}

	/**
	 * Normalize WordPress-owned delivery metadata without exposing it upstream.
	 *
	 * Product delivery remains explicit and the exact destination adapter decides
	 * whether the requested action is valid for the target.
	 *
	 * @param mixed $raw Raw value.
	 * @return array{assignmentMode:string}|array{}|\WP_Error
	 */
	private function normalize_result_policy( $raw, string $workflow, string $source ) {
		$policy = $this->object_members( $raw, 'resultPolicy' );
		if ( is_wp_error( $policy ) ) {
			return $policy;
		}

		$product_delivery = 'product' === $source && in_array( $workflow, [ 'product_images', 'campaign_set' ], true );
		if ( ! $product_delivery ) {
			return empty( $policy ) ? [] : $this->unexpected_fields( 'resultPolicy' );
		}

		$shape = $this->validate_exact_fields( $policy, self::RESULT_POLICY_FIELDS, self::RESULT_POLICY_FIELDS, 'resultPolicy' );
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}
		$assignment_mode = $policy['assignmentMode'];
		if ( ! is_string( $assignment_mode ) ) {
			return $this->type_error( 'resultPolicy.assignmentMode' );
		}
		$allowed = 'campaign_set' === $workflow
			? [ 'review', 'gallery_append' ]
			: [ 'review', 'gallery_append', 'featured_replace' ];
		if ( ! in_array( $assignment_mode, $allowed, true ) ) {
			return $this->invalid( __( 'Select a supported Product delivery setting.', 'fotogenic-ai' ) );
		}

		return [ 'assignmentMode' => $assignment_mode ];
	}

	/**
	 * @param mixed $raw_groups Raw Campaign groups.
	 * @return array{groups:array<int,array>}|\WP_Error
	 */
	private function normalize_campaign_groups( $raw_groups, string $source, string $scope, Workflow_Definition_Interface $definition ) {
		if ( ! is_array( $raw_groups ) || ! fotogenic_ai_array_is_list( $raw_groups ) || empty( $raw_groups ) ) {
			return $this->type_error( 'sourcePolicy.groups' );
		}
		$sources = $definition->get_sources();
		$input_contract = 'single' === $scope ? 'single' : 'bulkItem';
		$maximum_inputs = (int) ( $sources[ $source ]['inputCardinality'][ $input_contract ]['primary']['maximum'] ?? 0 );
		$maximum_groups = (int) ( $sources[ $source ]['selectionCardinality'][ $scope ]['maximum'] ?? 0 );
		if ( count( $raw_groups ) > $maximum_groups || $maximum_inputs <= 0 ) {
			return $this->invalid( __( 'The Campaign group count is outside the workflow limits.', 'fotogenic-ai' ) );
		}

		$groups = [];
		$group_ids = [];
		foreach ( $raw_groups as $index => $raw_group ) {
			$path = 'sourcePolicy.groups.' . $index;
			$group = $this->object_members( $raw_group, $path );
			if ( is_wp_error( $group ) ) {
				return $group;
			}
			$group_fields = [ 'id', 'ordinal', 'subjectId', 'target', 'primaryAttachmentId', 'supportingAttachmentIds', 'recipe', 'resultPolicy' ];
			$shape = $this->validate_exact_fields( $group, $group_fields, $group_fields, $path );
			if ( is_wp_error( $shape ) ) {
				return $shape;
			}
			$id = is_string( $group['id'] ) ? strtolower( trim( $group['id'] ) ) : '';
			$ordinal = $group['ordinal'] ?? null;
			$subject_id = $group['subjectId'] ?? null;
			$primary_id = $group['primaryAttachmentId'] ?? null;
			$supporting_ids = $this->normalize_id_list( $group['supportingAttachmentIds'], $path . '.supportingAttachmentIds' );
			$target = $this->object_members( $group['target'], $path . '.target' );
			$recipe = $this->normalize_schema_object(
				$group['recipe'],
				$definition->get_recipe_schema(),
				$path . '.recipe',
				$source,
				$scope
			);
			$result_policy = $this->normalize_result_policy( $group['resultPolicy'], 'campaign_set', $source );
			foreach ( [ $supporting_ids, $target, $recipe, $result_policy ] as $normalized_group_value ) {
				if ( is_wp_error( $normalized_group_value ) ) {
					return $normalized_group_value;
				}
			}
			if (
				! wp_is_uuid( $id )
				|| isset( $group_ids[ $id ] )
				|| ! is_int( $ordinal )
				|| $ordinal !== $index + 1
				|| ! is_int( $subject_id )
				|| $subject_id <= 0
				|| ! is_int( $primary_id )
				|| $primary_id <= 0
				|| in_array( $primary_id, $supporting_ids, true )
				|| count( $supporting_ids ) + 1 > $maximum_inputs
				|| [ 'targetType', 'targetId', 'parentProductId', 'variationId' ] !== array_keys( $target )
				|| ! is_string( $target['targetType'] )
				|| ! is_int( $target['targetId'] )
				|| ! is_int( $target['parentProductId'] )
				|| ! is_int( $target['variationId'] )
				|| $target['targetId'] !== $subject_id
			) {
				return $this->invalid( __( 'A Campaign group contains invalid immutable identity or source roles.', 'fotogenic-ai' ) );
			}
			$normalized_target = [
				'targetType'      => sanitize_key( $target['targetType'] ),
				'targetId'        => $target['targetId'],
				'parentProductId' => $target['parentProductId'],
				'variationId'     => $target['variationId'],
			];
			if ( ! $this->campaign_target_hint_is_valid( $normalized_target, $source ) ) {
				return $this->invalid( __( 'A Campaign group target is invalid.', 'fotogenic-ai' ) );
			}
			$group_ids[ $id ] = true;
			$groups[] = [
				'id'                      => $id,
				'ordinal'                 => $ordinal,
				'subjectId'               => $subject_id,
				'target'                  => $normalized_target,
				'primaryAttachmentId'     => $primary_id,
				'supportingAttachmentIds' => $supporting_ids,
				'recipe'                  => $recipe,
				'resultPolicy'            => $result_policy,
			];
		}

		return [ 'groups' => $groups ];
	}

	/** @return bool|\WP_Error */
	private function validate_campaign_group_selection( array $selection, array $policy, string $workflow, string $source, string $scope ) {
		$groups = $policy['groups'] ?? null;
		if ( 'campaign_set' !== $workflow ) {
			return null === $groups ? true : $this->invalid( __( 'Campaign groups are not valid for this workflow.', 'fotogenic-ai' ) );
		}
		if ( null === $groups ) {
			return true;
		}
		if ( 'explicit' !== (string) ( $selection['strategy'] ?? '' ) ) {
			return $this->invalid( __( 'Campaign groups require an explicit source selection.', 'fotogenic-ai' ) );
		}
		if ( 'single' === $scope && 1 !== count( $groups ) ) {
			return $this->invalid( __( 'Single Campaign execution requires exactly one immutable group.', 'fotogenic-ai' ) );
		}
		$selected = array_values( (array) ( $selection['selectedIds'] ?? [] ) );
		$group_subjects = array_values( array_unique( array_map( static fn( array $group ): int => (int) $group['subjectId'], $groups ) ) );
		sort( $selected, SORT_NUMERIC );
		sort( $group_subjects, SORT_NUMERIC );
		if ( $selected !== $group_subjects ) {
			return $this->invalid( __( 'Campaign groups must match the selected exact targets.', 'fotogenic-ai' ) );
		}
		if ( 'media' === $source && count( $groups ) !== count( $group_subjects ) ) {
			return $this->invalid( __( 'Each selected Media source must own exactly one Campaign group.', 'fotogenic-ai' ) );
		}
		return true;
	}

	private function campaign_target_hint_is_valid( array $target, string $source ): bool {
		if ( 'product' === $source ) {
			return ( 'product' === $target['targetType'] && 0 === $target['parentProductId'] && 0 === $target['variationId'] )
				|| ( 'variation' === $target['targetType'] && $target['parentProductId'] > 0 && $target['variationId'] === $target['targetId'] );
		}
		return 'media' === $source
			&& 'media' === $target['targetType']
			&& 0 === $target['parentProductId']
			&& 0 === $target['variationId'];
	}

	/**
	 * @param mixed  $raw    Raw recipe object.
	 * @param array  $schema Registry recipe schema.
	 * @param string $path   Safe field path for error context.
	 * @param string $source Normalized source type.
	 * @param string $scope  Normalized scope.
	 * @return array|\WP_Error
	 */
	private function normalize_schema_object( $raw, array $schema, string $path, string $source, string $scope ) {
		$members = $this->object_members( $raw, $path );
		if ( is_wp_error( $members ) ) {
			return $members;
		}
		$fields = isset( $schema['fields'] ) && is_array( $schema['fields'] ) ? $schema['fields'] : [];
		$shape  = $this->validate_exact_fields( $members, array_keys( $fields ), [], $path );
		if ( is_wp_error( $shape ) ) {
			return $shape;
		}

		$normalized = [];
		foreach ( $fields as $field => $field_schema ) {
			if ( ! is_array( $field_schema ) ) {
				return $this->invalid_schema();
			}
			$present = array_key_exists( $field, $members );
			if ( ! $present ) {
				if ( array_key_exists( 'default', $field_schema ) ) {
					$normalized[ $field ] = $field_schema['default'];
					continue;
				}
				if ( ! empty( $field_schema['required'] ) ) {
					return $this->invalid( __( 'The Studio recipe is missing a required field.', 'fotogenic-ai' ) );
				}
				continue;
			}

			if (
				( isset( $field_schema['sources'] ) && ! in_array( $source, (array) $field_schema['sources'], true ) )
				|| ( isset( $field_schema['scopes'] ) && ! in_array( $scope, (array) $field_schema['scopes'], true ) )
			) {
				return $this->invalid( __( 'A recipe field is not available for this source or mode.', 'fotogenic-ai' ) );
			}

			$value = $this->normalize_schema_value( $members[ $field ], $field_schema, $path . '.' . $field, $source, $scope );
			if ( is_wp_error( $value ) ) {
				return $value;
			}
			if ( $this->should_omit( $value, (string) ( $field_schema['omission'] ?? '' ) ) ) {
				continue;
			}
			$constraints = $this->validate_schema_constraints( $value, $field_schema );
			if ( is_wp_error( $constraints ) ) {
				return $constraints;
			}
			$normalized[ $field ] = $value;
		}

		foreach ( $fields as $field => $field_schema ) {
			if ( ! array_key_exists( $field, $normalized ) || empty( $field_schema['requires'] ) || ! is_array( $field_schema['requires'] ) ) {
				continue;
			}
			foreach ( $field_schema['requires'] as $dependency => $expected ) {
				$actual = $normalized[ $dependency ] ?? null;
				$valid  = 'positive-integer' === $expected
					? is_int( $actual ) && $actual > 0
					: $actual === $expected;
				if ( ! $valid ) {
					return $this->invalid( __( 'A recipe field requires its supporting reference.', 'fotogenic-ai' ) );
				}
			}
		}

		return $normalized;
	}

	/**
	 * @param mixed  $raw          Raw field value.
	 * @param array  $field_schema Field schema.
	 * @param string $path         Field path.
	 * @param string $source       Source type.
	 * @param string $scope        Scope.
	 * @return mixed|\WP_Error
	 */
	private function normalize_schema_value( $raw, array $field_schema, string $path, string $source, string $scope ) {
		$type = (string) ( $field_schema['type'] ?? '' );
		if ( 'integer' === $type ) {
			return is_int( $raw ) ? $raw : $this->type_error( $path );
		}
		if ( 'string' === $type ) {
			return is_string( $raw ) ? trim( $raw ) : $this->type_error( $path );
		}
		if ( 'boolean' === $type ) {
			return is_bool( $raw ) ? $raw : $this->type_error( $path );
		}
		if ( 'object' === $type ) {
			return $this->normalize_schema_object( $raw, $field_schema, $path, $source, $scope );
		}

		return $this->invalid_schema();
	}

	/**
	 * @param mixed $value        Normalized field value.
	 * @param array $field_schema Field schema.
	 * @return bool|\WP_Error
	 */
	private function validate_schema_constraints( $value, array $field_schema ) {
		if ( isset( $field_schema['minimum'] ) && is_int( $value ) && $value < (int) $field_schema['minimum'] ) {
			return $this->invalid( __( 'A Studio recipe value is below its allowed minimum.', 'fotogenic-ai' ) );
		}
		if ( isset( $field_schema['maximum'] ) && is_int( $value ) && $value > (int) $field_schema['maximum'] ) {
			return $this->invalid( __( 'A Studio recipe value exceeds its allowed maximum.', 'fotogenic-ai' ) );
		}
		if ( isset( $field_schema['maxLength'] ) && is_string( $value ) ) {
			$length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value );
			if ( $length > (int) $field_schema['maxLength'] ) {
				return $this->invalid( __( 'A Studio recipe value is too long.', 'fotogenic-ai' ) );
			}
		}
		if ( isset( $field_schema['enum'] ) && ! in_array( $value, (array) $field_schema['enum'], true ) ) {
			return $this->invalid( __( 'A Studio recipe value is not supported.', 'fotogenic-ai' ) );
		}

		return true;
	}

	/** @param mixed $value Candidate value. */
	private function should_omit( $value, string $rule ): bool {
		if ( in_array( $rule, [ 'empty', 'missing-empty-or-auto' ], true ) && ( '' === $value || [] === $value ) ) {
			return true;
		}
		if ( 'missing-or-zero' === $rule && 0 === $value ) {
			return true;
		}
		if ( 'missing-empty-or-auto' !== $rule ) {
			return false;
		}
		if ( is_string( $value ) ) {
			return 'auto' === strtolower( $value );
		}

		return is_array( $value )
			&& isset( $value['id'] )
			&& is_string( $value['id'] )
			&& 'auto' === strtolower( $value['id'] );
	}

	/**
	 * @param mixed  $raw  Raw ID list.
	 * @param string $path Safe path used only for a generic type message.
	 * @return int[]|\WP_Error
	 */
	private function normalize_id_list( $raw, string $path ) {
		if ( ! is_array( $raw ) || ! fotogenic_ai_array_is_list( $raw ) ) {
			return $this->type_error( $path );
		}

		$ids = [];
		foreach ( $raw as $value ) {
			if ( ! is_int( $value ) || $value <= 0 || in_array( $value, $ids, true ) ) {
				return $this->invalid( __( 'Source IDs must be unique positive integers.', 'fotogenic-ai' ) );
			}
			$ids[] = $value;
		}

		return $ids;
	}

	/**
	 * @param mixed  $value Raw object value.
	 * @param string $path  Safe field path.
	 * @return array|\WP_Error
	 */
	private function object_members( $value, string $path ) {
		if ( $value instanceof \stdClass ) {
			return get_object_vars( $value );
		}

		return $this->type_error( $path );
	}

	/**
	 * @param array    $members Object members.
	 * @param string[] $allowed Allowed keys.
	 * @param string[] $required Required keys.
	 * @param string   $path     Safe object path.
	 * @return bool|\WP_Error
	 */
	private function validate_exact_fields( array $members, array $allowed, array $required, string $path ) {
		if ( ! empty( array_diff( array_keys( $members ), $allowed ) ) ) {
			return $this->unexpected_fields( $path );
		}
		if ( ! empty( array_diff( $required, array_keys( $members ) ) ) ) {
			return $this->invalid( __( 'The Studio draft is missing required metadata.', 'fotogenic-ai' ) );
		}

		return true;
	}

	/**
	 * @param mixed $value Canonical value candidate.
	 * @param bool  $valid Whether every value remains JSON-safe.
	 * @return mixed
	 */
	private function canonicalize( $value, bool &$valid ) {
		if ( is_array( $value ) ) {
			if ( ! fotogenic_ai_array_is_list( $value ) ) {
				ksort( $value, SORT_STRING );
			}
			foreach ( $value as $key => $item ) {
				$value[ $key ] = $this->canonicalize( $item, $valid );
			}
			return $value;
		}
		if ( is_string( $value ) || is_int( $value ) || is_bool( $value ) || null === $value ) {
			return $value;
		}

		$valid = false;
		return null;
	}

	/** @return \stdClass|\WP_Error */
	private function rehydrate_schema_object( array $value, array $schema ) {
		if ( fotogenic_ai_array_is_list( $value ) && ! empty( $value ) ) {
			return $this->type_error( 'recipe' );
		}
		$fields = is_array( $schema['fields'] ?? null ) ? $schema['fields'] : [];
		foreach ( $value as $field => $field_value ) {
			$field_schema = $fields[ $field ] ?? null;
			if ( ! is_array( $field_schema ) ) {
				return $this->unexpected_fields( 'recipe' );
			}
			if ( 'object' !== (string) ( $field_schema['type'] ?? '' ) ) {
				continue;
			}
			if ( ! is_array( $field_value ) ) {
				return $this->type_error( 'recipe' );
			}
			$field_value = $this->rehydrate_schema_object( $field_value, $field_schema );
			if ( is_wp_error( $field_value ) ) {
				return $field_value;
			}
			$value[ $field ] = $field_value;
		}

		return (object) $value;
	}

	private function type_error( string $path ): \WP_Error {
		unset( $path );
		return new \WP_Error(
			'fotogenic_ai_invalid_draft_type',
			__( 'The Studio draft contains a value of the wrong JSON type.', 'fotogenic-ai' ),
			[ 'status' => 400 ]
		);
	}

	private function unexpected_fields( string $path ): \WP_Error {
		unset( $path );
		return new \WP_Error(
			'fotogenic_ai_unexpected_draft_field',
			__( 'The Studio draft contains unsupported fields.', 'fotogenic-ai' ),
			[ 'status' => 400 ]
		);
	}

	private function invalid( string $message ): \WP_Error {
		return new \WP_Error( 'fotogenic_ai_invalid_studio_draft', $message, [ 'status' => 400 ] );
	}

	private function invalid_schema(): \WP_Error {
		return new \WP_Error(
			'fotogenic_ai_invalid_workflow_definition',
			__( 'The workflow definition is temporarily unavailable.', 'fotogenic-ai' ),
			[ 'status' => 500 ]
		);
	}
}
