<?php

namespace Fotogenic_AI;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Bulk service cleanup queries use plugin-owned custom tables and normalized prepared placeholder lists.

final class Service_Bulk {
    public const HOUSEKEEPING_HOOK = 'fotogenic_ai_bulk_housekeeping';

	private const MAX_RUN_ITEMS = Workflow_Operational_Limits::MAX_RUN_ITEMS;
    private const UNIVERSAL_MAX_SEARCH_LENGTH = 200;
    private const UNIVERSAL_MAX_CATEGORY_FILTERS = 50;
    private const UNIVERSAL_STOCK_STATUSES = [ 'instock', 'outofstock', 'onbackorder' ];
    private const UNIVERSAL_MEDIA_FILTERS = [
        'type'        => [ 'all', 'jpg', 'png', 'webp' ],
        'date'        => [ 'all', 'today', 'week', 'month', 'older' ],
        'orientation' => [ 'all', 'square', 'landscape', 'portrait' ],
        'size'        => [ 'all', 'under_300kb', '300kb_and_over' ],
    ];
    private const UNIVERSAL_QUERY_FIELDS = [
        'product' => [ 'search', 'categories', 'stock' ],
        'media'   => [ 'search', 'type', 'date', 'orientation', 'size' ],
        'post'    => [ 'search' ],
        'page'    => [ 'search' ],
    ];
    private const BULK_RUN_RETENTION_SECONDS = 2592000;
    private const HOUSEKEEPING_INTERVAL_SECONDS = 3600;
    private const HOUSEKEEPING_CONTINUATION_SECONDS = 60;
    private const HOUSEKEEPING_LOCK_TTL_SECONDS = 300;
    private const HOUSEKEEPING_BATCH_SIZE = 25;
    private const HOUSEKEEPING_STATE_TTL_SECONDS = 172800;
    private const HOUSEKEEPING_STATE_TRANSIENT = 'fotogenic_ai_bulk_housekeeping_state';
    private const HOUSEKEEPING_LOCK_OPTION = 'fotogenic_ai_bulk_housekeeping_lock';

    private Bulk_Repo $repo;

    public function __construct() {
        $this->repo = Bulk_Repo::instance();
    }

    public static function handle_scheduled_housekeeping(): void {
        if ( ! Background_Runtime_State::is_enabled() ) {
            wp_unschedule_hook( self::HOUSEKEEPING_HOOK );
            return;
        }
        ( new self() )->run_bulk_housekeeping();
    }

    /**
     * Freeze and price one Bulk draft without persistence, scheduling, or dispatch.
     *
     * @param mixed $raw_draft Decoded universal draft object.
     * @return \WP_REST_Response|\WP_Error
     */
    public function snapshot( $raw_draft ) {
        if ( ! $this->user_can_manage_bulk() || ! current_user_can( 'upload_files' ) ) {
            return Messages::error( 'forbidden', 403 );
        }

        $normalizer = new Workflow_Draft_Normalizer();
        $draft = $normalizer->normalize( $raw_draft );
        if ( is_wp_error( $draft ) ) {
            return $draft;
        }
        if ( 'bulk' !== (string) ( $draft['scope'] ?? '' ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_scope_required',
                __( 'The Bulk snapshot route requires a Bulk draft.', 'fotogenic-ai' ),
                [ 'status' => 400 ]
            );
        }
        $workflow = (string) $draft['workflow'];
        $source_type = (string) $draft['sourceType'];
        if ( ! Workflow_Registry::supports_new_run( $workflow, $source_type, 'bulk' ) ) {
            return $this->universal_single_source_workflow_error();
        }
        $maximum = $this->get_universal_bulk_maximum( $workflow, $source_type );
        if ( $maximum < 1 ) {
            return Messages::error( 'invalid_request', 400 );
        }

		$expanded = $this->expand_universal_bulk_selection( $draft, $maximum );
		if ( is_wp_error( $expanded ) ) {
			return $expanded;
		}
		if (
			'product_images' === $workflow
			&& 1 === count( (array) ( $expanded['selection']['selectedIds'] ?? [] ) )
		) {
			$expanded['scope'] = 'single';
		}
		$expanded_object = $normalizer->rehydrate_normalized( $expanded );
        if ( is_wp_error( $expanded_object ) ) {
            return $expanded_object;
        }
        $expanded = $normalizer->normalize( $expanded_object );
        if ( is_wp_error( $expanded ) ) {
            return $expanded;
        }

        $sources = $this->build_exact_source_snapshot( $expanded );
        if ( is_wp_error( $sources ) ) {
            return $sources;
        }
        if ( count( $sources ) < 1 || count( $sources ) > $maximum ) {
            return $this->universal_bulk_limit_error( $maximum );
        }

        $preflight = $this->evaluate_universal_bulk_preflight( $expanded, $normalizer );
        if ( is_wp_error( $preflight ) ) {
            return $preflight;
        }
        $fingerprint = $normalizer->fingerprint( $expanded );
        if ( is_wp_error( $fingerprint ) ) {
            return $fingerprint;
        }
        if ( ! hash_equals( $fingerprint, (string) ( $preflight['draftFingerprint'] ?? '' ) ) ) {
            return Messages::error( 'server_error', 500 );
        }
        $snapshot_fingerprint = $this->universal_bulk_snapshot_fingerprint( $expanded, $sources, $normalizer );
        if ( is_wp_error( $snapshot_fingerprint ) ) {
            return $snapshot_fingerprint;
        }

        $summaries = $this->build_universal_bulk_summaries( $source_type, (array) $expanded['selection']['selectedIds'] );
        if ( is_wp_error( $summaries ) ) {
            return $summaries;
        }
        $browser_draft = $normalizer->rehydrate_normalized( $expanded );
        if ( is_wp_error( $browser_draft ) ) {
            return $browser_draft;
        }

        $response = rest_ensure_response(
            [
                'snapshotVersion'      => 1,
                'draft'                => $browser_draft,
                'draftFingerprint'     => $fingerprint,
                'snapshotFingerprint'  => $snapshot_fingerprint,
                'preflight'            => $preflight,
                'selected'             => $summaries,
                'selectedCount'        => count( $sources ),
                'selectedSubjectCount' => count( $summaries ),
                'maximum'              => $maximum,
            ]
        );
        $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate' );
        return $response;
    }

	/**
	 * Admit and schedule one canonical universal Bulk run.
	 *
	 * The canonical draft normalizer owns the shared contract. This service
	 * expands bounded Bulk queries, freezes each authorized source graph, checks
	 * authoritative total price/credits before first activation, and persists
	 * every stable item identity before a worker can claim it.
	 *
	 * @param mixed $raw_draft Decoded universal draft object.
	 * @param mixed $accepted_authority Accepted authority proof.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function start( string $run_uuid, $raw_draft, string $snapshot_fingerprint, $accepted_authority ) {
		return $this->start_internal( $run_uuid, $raw_draft, $snapshot_fingerprint, $accepted_authority, false );
	}

	/**
	 * Admit a bounded Store Queue child through the exact Bulk authority path.
	 *
	 * The only difference from the browser route is that a final one-item child
	 * remains Bulk-owned instead of being redirected to the Single endpoint.
	 *
	 * @param mixed $raw_draft Decoded universal draft object.
	 * @param mixed $accepted_authority Accepted authority proof.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function start_exact_queue_child( string $run_uuid, $raw_draft, string $snapshot_fingerprint, $accepted_authority ) {
		return $this->start_internal( $run_uuid, $raw_draft, $snapshot_fingerprint, $accepted_authority, true );
	}

	/** @return \WP_REST_Response|\WP_Error */
	private function start_internal( string $run_uuid, $raw_draft, string $snapshot_fingerprint, $accepted_authority, bool $allow_single_child ) {
        $migration = Universal_Data_Migration::instance();
        if ( ! $migration->is_ready() ) {
            return $migration->readiness_error();
        }
        if ( ! $this->user_can_manage_bulk() || ! current_user_can( 'upload_files' ) ) {
            return Messages::error( 'forbidden', 403 );
        }
        $run_uuid = strtolower( trim( $run_uuid ) );
        if ( ! wp_is_uuid( $run_uuid ) ) {
            return Messages::error( 'invalid_request', 400 );
        }

        $normalizer = new Workflow_Draft_Normalizer();
        $draft = $normalizer->normalize( $raw_draft );
        if ( is_wp_error( $draft ) ) {
            return $draft;
        }
        if ( 'bulk' !== (string) ( $draft['scope'] ?? '' ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_scope_required',
                __( 'The Bulk start route requires a Bulk draft.', 'fotogenic-ai' ),
                [ 'status' => 400 ]
            );
        }
        if ( 'explicit' !== (string) ( $draft['selection']['strategy'] ?? '' ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_snapshot_required',
                __( 'Prepare an immutable Bulk snapshot before starting generation.', 'fotogenic-ai' ),
                [ 'status' => 409, 'state' => 'requires_snapshot' ]
            );
        }
        $snapshot_fingerprint = strtolower( trim( $snapshot_fingerprint ) );
        if ( 1 !== preg_match( '/^[a-f0-9]{64}$/', $snapshot_fingerprint ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_snapshot_required',
                __( 'Prepare an immutable Bulk snapshot before starting generation.', 'fotogenic-ai' ),
                [ 'status' => 409, 'state' => 'requires_snapshot' ]
            );
        }

        $workflow = (string) $draft['workflow'];
        $source_type = (string) $draft['sourceType'];
        if ( ! Workflow_Registry::supports_new_run( $workflow, $source_type, 'bulk' ) ) {
            return $this->universal_single_source_workflow_error();
        }
        $maximum = $this->get_universal_bulk_maximum( $workflow, $source_type );
        if ( $maximum < 1 ) {
            return Messages::error( 'invalid_request', 400 );
        }
        $fingerprint = $normalizer->fingerprint( $draft );
        if ( is_wp_error( $fingerprint ) ) {
            return $fingerprint;
        }

        $user_id = get_current_user_id();
		$existing = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
		if (
			! is_array( $existing )
			&& 'product_images' === $workflow
			&& count( (array) ( $draft['selection']['selectedIds'] ?? [] ) ) < 2
			&& ! $allow_single_child
		) {
			return new \WP_Error(
				'fotogenic_ai_generate_single_route_required',
				__( 'One exact Generate Images source must use the Single start route.', 'fotogenic-ai' ),
				[ 'status' => 409, 'state' => 'single_route_required' ]
			);
		}
		if ( is_array( $existing ) ) {
            if ( ! $this->bulk_fingerprint_matches( $existing, $fingerprint ) ) {
                return $this->universal_bulk_conflict( $run_uuid );
            }
            $snapshot = $this->decode_filters_snapshot( $existing['filters_snapshot'] ?? '' );
			$expanded_object = is_array( $snapshot['draft'] ?? null )
				? $normalizer->rehydrate_normalized( $snapshot['draft'] )
				: Messages::error( 'server_error', 500 );
			$expanded = is_wp_error( $expanded_object ) ? $expanded_object : $normalizer->normalize( $expanded_object );
            $sources = is_array( $snapshot['sources'] ?? null ) ? $snapshot['sources'] : [];
            if ( is_wp_error( $expanded ) || empty( $sources ) ) {
                return new \WP_Error(
                    'fotogenic_ai_bulk_snapshot_invalid',
                    __( 'The saved Bulk source snapshot is invalid and cannot be scheduled.', 'fotogenic-ai' ),
					[ 'status' => 409, 'runUuid' => $run_uuid ]
                );
            }
        } else {
            $expanded = $this->expand_universal_bulk_selection( $draft, $maximum );
            if ( is_wp_error( $expanded ) ) {
                return $expanded;
            }
            $expanded_object = $normalizer->rehydrate_normalized( $expanded );
            $expanded = is_wp_error( $expanded_object ) ? $expanded_object : $normalizer->normalize( $expanded_object );
            if ( is_wp_error( $expanded ) ) {
                return $expanded;
            }
            $sources = $this->build_exact_source_snapshot( $expanded );
            if ( is_wp_error( $sources ) ) {
                return $sources;
            }
            $snapshot = [
                'schemaVersion' => 1,
                'draft'         => $expanded,
                'sources'       => $sources,
            ];
        }

        $current_snapshot_fingerprint = $this->universal_bulk_snapshot_fingerprint( $expanded, $sources, $normalizer );
        if (
            is_wp_error( $current_snapshot_fingerprint )
            || ! hash_equals( $current_snapshot_fingerprint, $snapshot_fingerprint )
        ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_snapshot_changed',
                __( 'A selected source changed after Review. Prepare a new Bulk snapshot before generation.', 'fotogenic-ai' ),
                [ 'status' => 409, 'state' => 'snapshot_changed' ]
            );
        }

        $items = $this->build_exact_child_item_graph( $run_uuid, $expanded, $sources, $normalizer );
        if ( is_wp_error( $items ) ) {
            return $items;
        }
        if ( empty( $items ) || count( $items ) > $maximum ) {
            return $this->universal_bulk_limit_error( $maximum );
        }
		$output_count = 'increase_resolution' === $workflow
			? count( $items )
			: ( 'campaign_set' === $workflow
				? array_sum(
					array_map(
						static fn( array $group ): int => (int) ( $group['recipe']['count'] ?? 0 ),
						(array) ( $expanded['sourcePolicy']['groups'] ?? [] )
					)
				)
				: count( $items ) * (int) ( $expanded['recipe']['count'] ?? 0 ) );
		if ( 'campaign_set' === $workflow && $output_count > Workflow_Operational_Limits::MAX_RUN_OUTPUTS ) {
            return new \WP_Error(
                'fotogenic_ai_campaign_output_limit',
				sprintf(
					/* translators: %d: Maximum outputs allowed in one Campaign Set Bulk run. */
					__( 'Campaign Set supports no more than %d outputs in one Bulk run.', 'fotogenic-ai' ),
					Workflow_Operational_Limits::MAX_RUN_OUTPUTS
				),
				[
					'status'  => 400,
					'maximum' => Workflow_Operational_Limits::MAX_RUN_OUTPUTS,
				]
            );
        }

		$requires_activation = ! is_array( $existing )
			|| (
				'paused' === (string) ( $existing['status'] ?? '' )
				&& in_array( (string) ( $existing['status_reason'] ?? '' ), [ 'not_integrated', 'authority_changed' ], true )
			);
		$preflight = null;
		if ( $requires_activation ) {
			$preflight = $this->preflight_universal_bulk( $expanded, $normalizer, $accepted_authority );
			if ( is_wp_error( $preflight ) ) {
				return $preflight;
			}
			$accepted_snapshot = $this->durable_bulk_authority_snapshot( $preflight );
			if ( is_wp_error( $accepted_snapshot ) ) {
				return $accepted_snapshot;
			}
			$snapshot['acceptedAuthority'] = $accepted_snapshot;
        }
        if ( ! $this->repo->acquire_admission_lock( $user_id ) ) {
            $replay = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
            if (
                is_array( $replay )
                && $this->bulk_fingerprint_matches( $replay, $fingerprint )
                && 'not_integrated' !== (string) ( $replay['status_reason'] ?? '' )
                && $this->repo->run_has_exact_graph( (int) $replay['id'], $user_id, $workflow, $items )
            ) {
                return $this->universal_bulk_response( $replay, true );
            }
            return $this->bulk_lifecycle_busy_error( $run_uuid );
        }

        try {
			$repair_graph = false;
            $existing = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
            if ( is_array( $existing ) ) {
                if ( ! $this->bulk_fingerprint_matches( $existing, $fingerprint ) ) {
                    return $this->universal_bulk_conflict( $run_uuid );
                }
				if ( ! $this->repo->run_has_exact_graph( (int) $existing['id'], $user_id, $workflow, $items ) ) {
					if ( ! $this->universal_campaign_graph_repair_allowed( $existing, $user_id, $items ) ) {
						return $this->universal_bulk_conflict( $run_uuid );
					}
					$repair_graph = true;
				} elseif ( $this->universal_campaign_activation_required( $existing, $user_id, $items ) ) {
					$repair_graph = true;
                }
            } else {
                $active = $this->get_active_run( $user_id );
                if ( ! empty( $active ) ) {
                    return Messages::error( 'invalid_request', 409, [], [
                        'message'  => __( 'A bulk run is already active.', 'fotogenic-ai' ),
						'runUuid' => (string) ( $active['run_uuid'] ?? '' ),
                    ] );
                }
            }

            $admission = [ 'created' => false, 'activated' => false ];
			if ( ! is_array( $existing ) || $repair_graph ) {
				$existing_snapshot = is_array( $existing )
					? (string) ( $existing['filters_snapshot'] ?? '' )
					: wp_json_encode( $snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
				$target_status = is_array( $existing ) && 'paused' === (string) ( $existing['status'] ?? '' )
					&& ! in_array( (string) ( $existing['status_reason'] ?? '' ), [ 'admitting', 'admission_incomplete', 'item_persistence_failed' ], true )
					? 'paused'
					: 'running';
                $admission = $this->repo->admit_run(
                    [
                        'run_uuid'            => $run_uuid,
                        'user_id'             => $user_id,
                        'workflow'            => $workflow,
                        'request_fingerprint' => $fingerprint,
						'status'              => $target_status,
						'status_reason'       => 'paused' === $target_status ? (string) ( $existing['status_reason'] ?? '' ) : '',
						'last_error'          => 'paused' === $target_status ? (string) ( $existing['last_error'] ?? '' ) : '',
						'started_at'          => is_array( $existing ) && ! empty( $existing['started_at'] )
							? (string) $existing['started_at']
							: current_time( 'mysql', true ),
						'filters_snapshot'    => $existing_snapshot,
	                        'selected_count'      => count( $items ),
	                    ],
                    $items
                );
                if ( empty( $admission['ok'] ) ) {
					if ( $repair_graph || 'request_fingerprint_conflict' === (string) ( $admission['reason'] ?? '' ) ) {
                        return $this->universal_bulk_conflict( $run_uuid );
                    }
                    return Messages::error( 'server_error', 500 );
                }
                $run = is_array( $admission['run'] ?? null )
                    ? $admission['run']
                    : $this->repo->get_run_by_id( (int) $admission['run_id'], $user_id );
			} else {
                $run = $existing;
                if (
                    'paused' === (string) ( $existing['status'] ?? '' )
					&& in_array( (string) ( $existing['status_reason'] ?? '' ), [ 'not_integrated', 'authority_changed' ], true )
				) {
					if ( ! is_array( $preflight ) ) {
						$preflight = $this->preflight_universal_bulk( $expanded, $normalizer, $accepted_authority );
						if ( is_wp_error( $preflight ) ) {
							return $preflight;
						}
						$accepted_snapshot = $this->durable_bulk_authority_snapshot( $preflight );
						if ( is_wp_error( $accepted_snapshot ) ) {
							return $accepted_snapshot;
						}
						$snapshot['acceptedAuthority'] = $accepted_snapshot;
					}
					$activated = $this->repo->update_run_if_status(
                        (int) $existing['id'],
                        $user_id,
                        'paused',
                        [
                            'status'           => 'running',
                            'status_reason'    => '',
							'started_at'       => current_time( 'mysql', true ),
							'last_error'       => null,
								'filters_snapshot' => wp_json_encode( $snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
						],
                        $workflow
                    );
                    if ( ! $activated ) {
                        return $this->bulk_lifecycle_busy_error( $run_uuid );
                    }
                    $admission['activated'] = true;
                    $run = $this->repo->get_run_by_id( (int) $existing['id'], $user_id );
                }
            }
        } finally {
            $this->repo->release_admission_lock( $user_id );
        }

        if ( ! is_array( $run ) ) {
            return Messages::error( 'server_error', 500 );
        }
        if ( 'running' === (string) ( $run['status'] ?? '' ) ) {
            $scheduled = Bulk_Scheduler::instance()->has_scheduled_tick( $run_uuid, $user_id )
                || Bulk_Scheduler::instance()->schedule_tick( $run_uuid, $user_id, 2 );
            if ( ! $scheduled ) {
                $this->repo->update_run_if_status(
                    (int) $run['id'],
                    $user_id,
                    'running',
                    [
                        'status'        => 'paused',
                        'status_reason' => 'scheduler_unavailable',
                        'last_error'    => 'Bulk scheduler could not be started.',
                    ],
                    $workflow
                );
                return new \WP_Error(
                    'fotogenic_ai_bulk_scheduler_unavailable',
                    __( 'The Bulk run was saved but WordPress could not schedule it.', 'fotogenic-ai' ),
					[ 'status' => 503, 'runUuid' => $run_uuid ]
                );
            }
        }

        return $this->universal_bulk_response( $run, empty( $admission['created'] ) && empty( $admission['activated'] ) );
    }

	/**
	 * @param mixed $accepted_authority Accepted authority proof.
	 * @return array|\WP_Error
	 */
	private function preflight_universal_bulk( array $expanded, Workflow_Draft_Normalizer $normalizer, $accepted_authority ) {
		$preflight = $this->evaluate_universal_bulk_preflight( $expanded, $normalizer, $accepted_authority );
        if ( is_wp_error( $preflight ) ) {
            return $preflight;
        }
        if ( (float) ( $preflight['balanceAfter'] ?? -1 ) < 0 ) {
            return new \WP_Error(
                'fotogenic_ai_insufficient_credits',
                __( 'There are not enough credits to start this Bulk run.', 'fotogenic-ai' ),
                [
                    'status'       => 402,
                    'balance'      => (float) ( $preflight['balance'] ?? 0 ),
                    'totalCredits' => (float) ( $preflight['totalCredits'] ?? 0 ),
                ]
            );
        }
        return $preflight;
    }

	/**
	 * @param mixed $accepted_authority Accepted authority proof.
	 * @return array|\WP_Error
	 */
	private function evaluate_universal_bulk_preflight( array $expanded, Workflow_Draft_Normalizer $normalizer, $accepted_authority = null ) {
        $draft = $normalizer->rehydrate_normalized( $expanded );
        if ( is_wp_error( $draft ) ) {
            return $draft;
        }
		$preflight_service = new Workflow_Preflight();
		$preflight = null === $accepted_authority
			? $preflight_service->evaluate( $draft )
			: $preflight_service->evaluate( $draft, $accepted_authority );
		if ( is_wp_error( $preflight ) ) {
			return $preflight;
		}
		return $preflight;
	}

	/** @return array|\WP_Error */
	private function durable_bulk_authority_snapshot( array $preflight ) {
		$authority = $preflight['authority'] ?? null;
		$evidence  = $preflight['authorityEvidence'] ?? null;
		$pricing   = $preflight['pricing'] ?? null;
		if (
			! is_array( $authority )
			|| ! is_array( $evidence )
			|| ! is_array( $pricing )
			|| Workflow_Accepted_Authority::VERSION !== ( $authority['version'] ?? null )
			|| 1 !== preg_match( '/^[a-f0-9]{64}$/', (string) ( $authority['fingerprint'] ?? '' ) )
			|| ! is_int( $authority['expiresAt'] ?? null )
			|| 1 !== preg_match( '/^[a-f0-9]{64}$/', (string) ( $evidence['contextFingerprint'] ?? '' ) )
			|| 1 !== preg_match( '/^[a-f0-9]{64}$/', (string) ( $evidence['configurationFingerprint'] ?? '' ) )
			|| 1 !== preg_match( '/^[a-f0-9]{64}$/', (string) ( $evidence['pricingFingerprint'] ?? '' ) )
			|| 1 !== preg_match( '/^[a-f0-9]{64}$/', (string) ( $evidence['creditsFingerprint'] ?? '' ) )
			|| ! is_int( $preflight['totalOutputs'] ?? null )
			|| $preflight['totalOutputs'] < 1
			|| $preflight['totalOutputs'] > Workflow_Operational_Limits::MAX_RUN_OUTPUTS
		) {
			return Messages::error( 'server_error', 500 );
		}

		return [
			'version'                  => Workflow_Accepted_Authority::VERSION,
			'authorityFingerprint'     => (string) $authority['fingerprint'],
			'authorityExpiresAt'       => (int) $authority['expiresAt'],
			'contextFingerprint'       => (string) $evidence['contextFingerprint'],
			'configurationFingerprint' => (string) $evidence['configurationFingerprint'],
			'pricingFingerprint'       => (string) $evidence['pricingFingerprint'],
			'creditsFingerprint'       => (string) $evidence['creditsFingerprint'],
			'pricing'                  => $pricing,
			'totalOutputs'             => $preflight['totalOutputs'],
			'balance'                  => (float) ( $preflight['balance'] ?? 0 ),
			'totalCredits'             => (float) ( $preflight['totalCredits'] ?? 0 ),
			'balanceAfter'             => (float) ( $preflight['balanceAfter'] ?? 0 ),
		];
	}

    private function get_universal_bulk_maximum( string $workflow, string $source_type ): int {
        $definition = Workflow_Registry::get( $workflow );
        $sources = null !== $definition ? $definition->get_sources() : [];
        return max( 0, (int) ( $sources[ $source_type ]['selectionCardinality']['bulk']['maximum'] ?? 0 ) );
    }

    /**
     * Resolve the only Bulk-specific draft primitive: a bounded query snapshot.
     *
     * @return array|\WP_Error
     */
    private function expand_universal_bulk_selection( array $draft, int $maximum ) {
        $selection = (array) ( $draft['selection'] ?? [] );
        if ( 'explicit' === (string) ( $selection['strategy'] ?? '' ) ) {
            return count( (array) ( $selection['selectedIds'] ?? [] ) ) <= $maximum
                ? $draft
                : $this->universal_bulk_limit_error( $maximum );
        }

        $source_type = (string) $draft['sourceType'];
		if ( 'product_images' !== (string) ( $draft['workflow'] ?? '' ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_query_workflow_unsupported',
                __( 'Select exact Campaign sources before continuing.', 'fotogenic-ai' ),
                [ 'status' => 400 ]
            );
		}
		$product_source_mode = '';
		if ( 'product' === $source_type ) {
			$source_policy = $draft['sourcePolicy'] ?? null;
			$product_source_mode = is_array( $source_policy )
				? (string) ( $source_policy['sourceMode'] ?? '' )
				: '';
			if ( ! in_array( $product_source_mode, [ 'featured', 'featured_gallery', 'gallery_only' ], true ) ) {
				return Messages::error( 'invalid_request', 400 );
			}
		}
		$query = (array) ( $selection['query'] ?? [] );
        $allowed = self::UNIVERSAL_QUERY_FIELDS[ $source_type ] ?? [];
        if ( empty( $allowed ) || ! empty( array_diff( array_keys( $query ), $allowed ) ) ) {
            return new \WP_Error( 'fotogenic_ai_invalid_bulk_query', __( 'The Bulk query contains unsupported fields.', 'fotogenic-ai' ), [ 'status' => 400 ] );
        }
        $query = $this->normalize_universal_bulk_query( $source_type, $query );
        if ( is_wp_error( $query ) ) {
            return $query;
        }
        /**
         * Filter the safe universal source query model before immutable expansion.
         *
         * Endpoint names, SQL fragments, file paths, prices, and identities are
         * not exposed. The complete result is revalidated and clamped below.
         *
         * @param array  $query       Normalized search/category/stock values.
         * @param string $source_type Product, Media, Post, or Page.
         * @param string $workflow    Universal workflow ID.
         */
        $query = apply_filters(
            'fotogenic_ai_studio_source_query_args',
            $query,
            $source_type,
            (string) $draft['workflow']
        );
        if ( ! is_array( $query ) || fotogenic_ai_array_is_list( $query ) || ! empty( array_diff( array_keys( $query ), $allowed ) ) ) {
            return new \WP_Error(
                'fotogenic_ai_invalid_bulk_query_filter',
                __( 'A source query filter returned unsupported values.', 'fotogenic-ai' ),
                [ 'status' => 400 ]
            );
        }
        $query = $this->normalize_universal_bulk_query( $source_type, $query );
        if ( is_wp_error( $query ) ) {
            return $query;
        }
        $included = (array) ( $selection['selectedIds'] ?? [] );
        $excluded = (array) ( $selection['excludedIds'] ?? [] );
        if ( count( $included ) > $maximum || count( $excluded ) > $maximum ) {
            return $this->universal_bulk_limit_error( $maximum );
        }
		$query_ids = $this->query_universal_bulk_ids( $source_type, $query, $maximum, $product_source_mode );
        if ( is_wp_error( $query_ids ) ) {
            return $query_ids;
        }
        $selected_ids = array_values( array_unique( array_merge( $query_ids, $included ) ) );
        $selected_ids = array_values( array_diff( $selected_ids, $excluded ) );
        if ( empty( $selected_ids ) || count( $selected_ids ) > $maximum ) {
            return $this->universal_bulk_limit_error( $maximum );
        }
        $draft['selection'] = [
            'strategy'    => 'explicit',
            'selectedIds' => $selected_ids,
            'excludedIds' => [],
            'query'       => [],
        ];
        return $draft;
    }

    /** @return array|\WP_Error */
    private function normalize_universal_bulk_query( string $source_type, array $query ) {
        $search = $query['search'] ?? '';
        if ( ! is_string( $search ) ) {
            return Messages::error( 'invalid_request', 400 );
        }
        $search = trim( sanitize_text_field( $search ) );
        $length = function_exists( 'mb_strlen' ) ? mb_strlen( $search ) : strlen( $search );
        if ( $length > self::UNIVERSAL_MAX_SEARCH_LENGTH ) {
            return Messages::error( 'invalid_request', 400 );
        }
        if ( 'media' === $source_type ) {
            $normalized = [ 'search' => $search ];
            foreach ( self::UNIVERSAL_MEDIA_FILTERS as $key => $allowed ) {
                $value = $query[ $key ] ?? 'all';
                if ( ! is_string( $value ) || ! in_array( $value, $allowed, true ) ) {
                    return Messages::error( 'invalid_request', 400 );
                }
                $normalized[ $key ] = $value;
            }
            return $normalized;
        }
        if ( 'product' !== $source_type ) {
            return [ 'search' => $search ];
        }
        $categories = $query['categories'] ?? [];
        $stock = $query['stock'] ?? [];
        if ( ! is_array( $categories ) || ! fotogenic_ai_array_is_list( $categories ) || ! is_array( $stock ) || ! fotogenic_ai_array_is_list( $stock ) ) {
            return Messages::error( 'invalid_request', 400 );
        }
        if ( count( $categories ) > self::UNIVERSAL_MAX_CATEGORY_FILTERS ) {
            return Messages::error( 'invalid_request', 400 );
        }
        foreach ( $categories as $category_id ) {
            if ( ! is_int( $category_id ) || $category_id <= 0 ) {
                return Messages::error( 'invalid_request', 400 );
            }
        }
        foreach ( $stock as $stock_status ) {
            if ( ! is_string( $stock_status ) || ! in_array( $stock_status, self::UNIVERSAL_STOCK_STATUSES, true ) ) {
                return Messages::error( 'invalid_request', 400 );
            }
        }
        $categories = array_values( array_unique( $categories ) );
        if ( ! empty( $categories ) ) {
            $terms = get_terms(
                [
                    'taxonomy'   => 'product_cat',
                    'include'    => $categories,
                    'fields'     => 'ids',
                    'hide_empty' => false,
                ]
            );
            $valid_categories = is_wp_error( $terms )
                ? []
                : array_values( array_unique( array_filter( array_map( 'absint', (array) $terms ) ) ) );
            sort( $valid_categories, SORT_NUMERIC );
            $expected_categories = $categories;
            sort( $expected_categories, SORT_NUMERIC );
            if ( $valid_categories !== $expected_categories ) {
                return new \WP_Error(
                    'fotogenic_ai_invalid_bulk_category',
                    __( 'One or more Product categories are unavailable.', 'fotogenic-ai' ),
                    [ 'status' => 400 ]
                );
            }
        }
        return [
            'search'     => $search,
            'categories' => $categories,
            'stock'      => array_values( array_unique( $stock ) ),
        ];
    }

    /**
     * Return the exact ordered subject summaries for the frozen Review screen.
     *
     * @param int[] $subject_ids Ordered authorized subject IDs.
     * @return array|\WP_Error
     */
	private function build_universal_bulk_summaries( string $source_type, array $subject_ids ) {
		$adapter = $this->get_universal_query_adapter( $source_type );
        if ( null === $adapter ) {
            return Messages::error( 'invalid_request', 400 );
        }

        $summaries = [];
        foreach ( $subject_ids as $subject_id ) {
            $subject_id = absint( $subject_id );
			$summary = null;
			switch ( $source_type ) {
				case 'product':
					$summary = $adapter->get_product_summary( $subject_id );
					break;
				case 'media':
					$summary = $adapter->get_media_summary( $subject_id );
					break;
				case 'post':
					$summary = $adapter->get_post_summary( $subject_id );
					break;
				case 'page':
					$summary = $adapter->get_page_summary( $subject_id );
					break;
			}
            if ( is_wp_error( $summary ) ) {
                return $summary;
            }
            if ( ! is_array( $summary ) || $subject_id !== absint( $summary['id'] ?? 0 ) ) {
                return new \WP_Error(
                    'fotogenic_ai_bulk_snapshot_subject_changed',
                    __( 'A selected source changed while the Bulk snapshot was prepared.', 'fotogenic-ai' ),
                    [ 'status' => 409 ]
                );
            }
            $target = Workflow_Item_Identity::resolve_target( $source_type, $subject_id );
            if ( is_wp_error( $target ) ) {
                return $target;
            }

			$label = sanitize_text_field( (string) ( $summary['label'] ?? '' ) );
			$image_url = esc_url_raw(
				(string) ( $summary['imageUrl'] ?? '' ),
                [ 'http', 'https' ]
            );
            $detail = '';
            if ( 'product' === $source_type && '' !== (string) ( $summary['sku'] ?? '' ) ) {
                $detail = sprintf( 'SKU %s', sanitize_text_field( (string) $summary['sku'] ) );
            } elseif ( ! empty( $summary['width'] ) && ! empty( $summary['height'] ) ) {
                $detail = sprintf( '%d × %d', absint( $summary['width'] ), absint( $summary['height'] ) );
            }
            /* translators: %d: WordPress source object ID. */
            $fallback_label = sprintf( __( 'Item %d', 'fotogenic-ai' ), $subject_id );
            $summaries[] = [
                'id'       => $subject_id,
                'label'    => '' !== $label ? $label : $fallback_label,
                'imageUrl' => $image_url,
                'detail'   => $detail,
                'eligible' => true,
                'target'   => $target,
                'sourceAttachmentId' => absint( $summary['sourceAttachmentId'] ?? 0 ),
                'sourceAvailability' => is_array( $summary['sourceAvailability'] ?? null )
                    ? $summary['sourceAvailability']
                    : null,
            ];
        }

        return $summaries;
    }

    /** @return int[]|\WP_Error */
	private function query_universal_bulk_ids( string $source_type, array $query, int $limit, string $product_source_mode = '' ) {
		$adapter = $this->get_universal_query_adapter( $source_type );
        if ( null === $adapter ) {
            return Messages::error( 'invalid_request', 400 );
        }
        $ids = [];
        $page = 1;
        $limit = max( 1, $limit );
        $per_page = min( 50, $limit );
        $maximum_pages = max( 1, (int) ceil( $limit / $per_page ) );
        do {
			$result = null;
			switch ( $source_type ) {
				case 'product':
					$args = [
						'status'   => 'publish',
						'limit'    => $per_page,
						'page'     => $page,
						'return'   => 'objects',
						'orderby'  => 'date',
						'order'    => 'DESC',
						'paginate' => true,
					];
					if ( '' !== $query['search'] ) {
						$args['search'] = self::normalize_products_search_query( $query['search'] );
					}
					if ( ! empty( $query['categories'] ) ) {
						$args['category'] = $this->normalize_category_filter( $query['categories'] );
					}
					if ( ! empty( $query['stock'] ) ) {
						$args['stock_status'] = $query['stock'];
					}
					$result = $adapter->list_products( $args );
					break;
				case 'media':
					$media_filters = $query;
					unset( $media_filters['search'] );
					$result = $adapter->list_media( $query['search'], $page, $per_page, $media_filters );
					break;
				case 'post':
					$result = $adapter->list_posts( $query['search'], $page, $per_page );
					break;
				case 'page':
					$result = $adapter->list_pages( $query['search'], $page, $per_page );
					break;
			}
            if ( is_wp_error( $result ) ) {
                return $result;
            }
            if ( ! is_array( $result ) ) {
                return Messages::error( 'server_error', 500 );
            }
            $batch = (array) ( $result['items'] ?? [] );
            $total_items = max( count( $batch ), (int) ( $result['totalItems'] ?? count( $batch ) ) );
            if ( 1 === $page && $total_items > $limit ) {
                return $this->universal_bulk_query_too_broad_error( $limit );
            }
            foreach ( $batch as $item ) {
                if ( ! is_array( $item ) || true !== ( $item['eligible'] ?? false ) ) {
                    continue;
                }
				if (
					'product' === $source_type
					&& ! $this->universal_product_matches_source_mode( $item, $product_source_mode )
				) {
					continue;
				}
                $id = absint( $item['id'] ?? 0 );
                if ( $id > 0 && ! in_array( $id, $ids, true ) ) {
                    $ids[] = $id;
                }
            }
            ++$page;
            $total_pages = min( $maximum_pages, max( 1, (int) ( $result['totalPages'] ?? 1 ) ) );
        } while ( $page <= $total_pages && ! empty( $batch ) );
        if ( empty( $ids ) ) {
            return new \WP_Error(
                'fotogenic_ai_bulk_query_no_eligible_sources',
                __( 'No eligible sources match the current filters. Change the filters and try again.', 'fotogenic-ai' ),
                [ 'status' => 400 ]
            );
        }
		return $ids;
	}

	private function universal_product_matches_source_mode( array $item, string $source_mode ): bool {
		$availability = $item['sourceAvailability'] ?? null;
		if ( ! is_array( $availability ) ) {
			return false;
		}
		if ( 'featured' === $source_mode ) {
			return absint( $availability['featuredCount'] ?? 0 ) > 0;
		}
		if ( 'gallery_only' === $source_mode ) {
			return absint( $availability['galleryCount'] ?? 0 ) > 0;
		}
		return 'featured_gallery' === $source_mode
			&& absint( $availability['totalCount'] ?? 0 ) > 0;
	}

	/**
	 * Return the canonical discovery adapter for one universal source type.
	 *
	 * @return ProductQueryAdapter|MediaQueryAdapter|PostQueryAdapter|PageQueryAdapter|null
	 */
	private function get_universal_query_adapter( string $source_type ) {
		switch ( $source_type ) {
			case 'product':
				return Plugin::instance()->compat()->get_product_query_adapter();
			case 'media':
				return Plugin::instance()->compat()->get_media_query_adapter();
			case 'post':
				return Plugin::instance()->compat()->get_post_query_adapter();
			case 'page':
				return Plugin::instance()->compat()->get_page_query_adapter();
			default:
				return null;
		}
	}

    /** @return array|\WP_Error */
	/**
	 * Freeze exact Product sources through the same owner used by browser Bulk.
	 *
	 * @return array|\WP_Error
	 */
	public function build_exact_source_snapshot( array $draft ) {
		return ( new Workflow_Source_Preflight() )->build_explicit_snapshot( $draft );
	}

	/**
	 * Build the immutable child item graph shared by browser Bulk and Store Queue.
	 *
	 * @return array|\WP_Error
	 */
	public function build_exact_child_item_graph( string $run_uuid, array $draft, array $sources, ?Workflow_Draft_Normalizer $normalizer = null ) {
		$normalizer = $normalizer ?? new Workflow_Draft_Normalizer();
		return $this->build_universal_bulk_items( $run_uuid, $draft, $sources, $normalizer );
	}

	/** @return string|\WP_Error */
	public function build_exact_snapshot_fingerprint( array $draft, array $sources, ?Workflow_Draft_Normalizer $normalizer = null ) {
		$normalizer = $normalizer ?? new Workflow_Draft_Normalizer();
		return $this->universal_bulk_snapshot_fingerprint( $draft, $sources, $normalizer );
	}

	private function build_universal_bulk_items( string $run_uuid, array $draft, array $sources, Workflow_Draft_Normalizer $normalizer ) {
        $expected_subject_ids = array_values( array_map( 'absint', (array) ( $draft['selection']['selectedIds'] ?? [] ) ) );
        if (
            empty( $expected_subject_ids )
            || count( $expected_subject_ids ) !== count( array_unique( $expected_subject_ids ) )
            || in_array( 0, $expected_subject_ids, true )
        ) {
            return Messages::error( 'server_error', 500 );
        }

        $items = [];
        $snapshot_subject_ids = [];
        foreach ( $sources as $index => $source ) {
            $subject_type = (string) ( $source['subjectType'] ?? '' );
            $subject_id = absint( $source['subjectId'] ?? 0 );
            $raw_attachment_ids = (array) ( $source['attachmentIds'] ?? [] );
            $attachment_ids = array_values( array_map( 'absint', $raw_attachment_ids ) );
            $target = Workflow_Item_Identity::normalize_target_snapshot( $source['target'] ?? null );
            $source_roles = is_array( $source['sourceRoles'] ?? null ) ? $source['sourceRoles'] : [];
            if (
                $subject_type !== (string) $draft['sourceType']
                || $subject_id <= 0
                || empty( $attachment_ids )
                || count( $attachment_ids ) !== count( array_unique( $attachment_ids ) )
                || in_array( 0, $attachment_ids, true )
                || is_wp_error( $target )
                || $subject_type !== (string) $target['sourceType']
                || $subject_id !== (int) $target['targetId']
                || [ 'primaryAttachmentId', 'supportingAttachmentIds' ] !== array_keys( $source_roles )
                || (int) $source_roles['primaryAttachmentId'] !== (int) $attachment_ids[0]
                || array_values( (array) $source_roles['supportingAttachmentIds'] ) !== array_values( array_slice( $attachment_ids, 1 ) )
            ) {
                return Messages::error( 'server_error', 500 );
            }
            $is_campaign = 'campaign_set' === (string) $draft['workflow'];
            $campaign_group_uuid = null;
            $item_ordinal = $index + 1;
            $command_source_policy = (array) ( $draft['sourcePolicy'] ?? [] );
            if ( $is_campaign && isset( $source['campaignGroupId'], $source['itemOrdinal'] ) ) {
                $campaign_group_uuid = strtolower( trim( (string) $source['campaignGroupId'] ) );
                $item_ordinal = (int) $source['itemOrdinal'];
                $draft_group = $draft['sourcePolicy']['groups'][ $index ] ?? null;
                if (
                    ! wp_is_uuid( $campaign_group_uuid )
                    || $item_ordinal !== $index + 1
                    || ! is_array( $draft_group )
                    || ! hash_equals( $campaign_group_uuid, (string) ( $draft_group['id'] ?? '' ) )
                    || $subject_id !== (int) ( $draft_group['subjectId'] ?? 0 )
                ) {
                    return Messages::error( 'server_error', 500 );
                }
                $command_group = $draft_group;
                $command_group['ordinal'] = 1;
                $command_source_policy = [ 'groups' => [ $command_group ] ];
            } elseif ( $is_campaign ) {
                $campaign_group_uuid = Workflow_Item_Identity::deterministic_uuid(
                    'fotogenic-ai-campaign-group',
                    strtolower( $run_uuid ),
                    (string) $item_ordinal,
                    (string) $target['targetType'],
                    (string) $target['targetId']
                );
            }
            $run_item_uuid = Workflow_Item_Identity::deterministic_uuid(
                'fotogenic-ai-bulk-item',
                strtolower( $run_uuid ),
                (string) ( $campaign_group_uuid ?? $item_ordinal ),
                (string) $target['targetType'],
                (string) $target['targetId']
            );
			$command_recipe = (array) ( $draft['recipe'] ?? [] );
			$destination_snapshot = (array) ( $draft['resultPolicy'] ?? [] );
			if ( $is_campaign && isset( $draft_group ) && is_array( $draft_group ) ) {
				$command_recipe = (array) ( $draft_group['recipe'] ?? [] );
				$destination_snapshot = (array) ( $draft_group['resultPolicy'] ?? [] );
			}
			$snapshot_subject_ids[] = $subject_id;
            $command = [
                'schemaVersion' => 2,
                'runItem'       => [
                    'id'          => $run_item_uuid,
                    'groupId'     => $campaign_group_uuid,
                    'ordinal'     => $item_ordinal,
                    'target'      => $target,
                    'sourceRoles' => $source_roles,
                    'destination' => $destination_snapshot,
                ],
                'draft'         => array_merge(
                    $draft,
                    [
                        'selection' => [
                            'strategy'    => 'explicit',
                            'selectedIds' => [ $subject_id ],
                            'excludedIds' => [],
                            'query'       => [],
						],
						'sourcePolicy' => $command_source_policy,
						'recipe'       => $command_recipe,
						'resultPolicy' => $destination_snapshot,
					]
                ),
                'sources'       => [
                    'subjectType'  => $subject_type,
                    'subjectId'    => $subject_id,
                    'attachmentIds'=> $attachment_ids,
                ],
            ];
            $request_fingerprint = $normalizer->fingerprint( $command );
            if ( is_wp_error( $request_fingerprint ) ) {
                return $request_fingerprint;
            }
            $items[] = [
                'run_item_uuid'       => $run_item_uuid,
                'campaign_group_uuid' => $campaign_group_uuid,
                'item_ordinal'        => $item_ordinal,
                'subject_type'        => $subject_type,
                'subject_id'          => $subject_id,
                'target_snapshot'     => $target,
                'source_roles_snapshot'=> $source_roles,
                'destination_snapshot'=> $destination_snapshot,
                'request_id'          => Workflow_Item_Identity::deterministic_uuid( 'fotogenic-ai-bulk-request', $run_item_uuid ),
                'request_fingerprint' => $request_fingerprint,
                'command_snapshot'    => wp_json_encode( $command, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
                'generation_key'      => sha1( $run_uuid . ':' . $run_item_uuid ),
            ];
        }

        $snapshot_subject_ids = array_values( array_unique( $snapshot_subject_ids ) );
        sort( $snapshot_subject_ids, SORT_NUMERIC );
        sort( $expected_subject_ids, SORT_NUMERIC );
        if ( $snapshot_subject_ids !== $expected_subject_ids ) {
            return Messages::error( 'server_error', 500 );
        }

        return $items;
    }

    /** @return string|\WP_Error */
    private function universal_bulk_snapshot_fingerprint( array $draft, array $sources, Workflow_Draft_Normalizer $normalizer ) {
        return $normalizer->fingerprint(
            [
                'schemaVersion' => 1,
                'draft'         => $draft,
                'sources'       => $sources,
            ]
        );
    }

    private function bulk_fingerprint_matches( array $run, string $fingerprint ): bool {
        $stored = strtolower( trim( (string) ( $run['request_fingerprint'] ?? '' ) ) );
        return 64 === strlen( $stored ) && hash_equals( $stored, $fingerprint );
    }

	private function universal_campaign_graph_repair_allowed( array $run, int $user_id, array $items ): bool {
		if (
			'campaign_set' !== (string) ( $run['workflow'] ?? '' )
			|| $user_id <= 0
			|| (int) ( $run['user_id'] ?? 0 ) !== $user_id
			|| count( $items ) !== (int) ( $run['selected_count'] ?? 0 )
		) {
			return false;
		}
		$status = sanitize_key( (string) ( $run['status'] ?? '' ) );
		if ( ! in_array( $status, [ 'admitting', 'running', 'paused' ], true ) ) {
			return false;
		}
		$snapshot = $this->decode_filters_snapshot( $run['filters_snapshot'] ?? '' );
		if ( ! array_key_exists( 'draft', $snapshot ) || ! array_key_exists( 'sources', $snapshot ) ) {
			return false;
		}
		$current_count = array_sum( $this->repo->count_items_by_status( (int) ( $run['id'] ?? 0 ), $user_id ) );
		return $current_count >= 0 && $current_count < count( $items );
	}

	private function universal_campaign_activation_required( array $run, int $user_id, array $items ): bool {
		$run_id   = (int) ( $run['id'] ?? 0 );
		$snapshot = $this->decode_filters_snapshot( $run['filters_snapshot'] ?? '' );
		if (
			$run_id <= 0
			|| $user_id <= 0
			|| (int) ( $run['user_id'] ?? 0 ) !== $user_id
			|| 'campaign_set' !== (string) ( $run['workflow'] ?? '' )
			|| ! array_key_exists( 'draft', $snapshot )
			|| ! array_key_exists( 'sources', $snapshot )
			|| count( $items ) !== (int) ( $run['selected_count'] ?? 0 )
		) {
			return false;
		}
		$status = sanitize_key( (string) ( $run['status'] ?? '' ) );
		$reason = sanitize_key( (string) ( $run['status_reason'] ?? '' ) );
		return 'admitting' === $status
			|| (
				in_array( $status, [ 'running', 'paused' ], true )
				&& in_array( $reason, [ 'admitting', 'admission_incomplete', 'item_persistence_failed' ], true )
			);
	}

    private function universal_bulk_response( array $run, bool $replay ): \WP_REST_Response {
        return rest_ensure_response( [
			'runUuid'          => (string) ( $run['run_uuid'] ?? '' ),
			'workflow'         => (string) ( $run['workflow'] ?? '' ),
			'status'           => $this->public_bulk_run_status( (string) ( $run['status'] ?? '' ) ),
			'statusReason'     => sanitize_key( (string) ( $run['status_reason'] ?? '' ) ),
			'selectedCount'    => (int) ( $run['selected_count'] ?? 0 ),
			'dispatchReady'    => true,
			'idempotentReplay' => $replay,
        ] );
    }

    private function universal_bulk_conflict( string $run_uuid ): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_bulk_idempotency_conflict',
            __( 'This Bulk run UUID is already bound to a different draft.', 'fotogenic-ai' ),
			[ 'status' => 409, 'runUuid' => $run_uuid ]
        );
    }

    private function universal_bulk_limit_error( int $maximum ): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_bulk_subject_limit',
            sprintf(
                /* translators: %d: maximum Bulk subjects. */
                __( 'Bulk supports up to %d subjects per run.', 'fotogenic-ai' ),
                $maximum
            ),
            [ 'status' => 400, 'maximum' => $maximum ]
        );
    }

    private function universal_bulk_query_too_broad_error( int $maximum ): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_bulk_query_too_broad',
            sprintf(
                /* translators: %d: maximum matching sources that can be resolved safely. */
                __( 'Narrow the filters to %d matching sources or fewer before selecting all.', 'fotogenic-ai' ),
                $maximum
            ),
            [ 'status' => 400, 'maximum' => $maximum ]
        );
    }

    private function universal_single_source_workflow_error(): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_single_source_workflow',
            __( 'Increase Resolution requires one exact source per new run.', 'fotogenic-ai' ),
            [ 'status' => 400 ]
        );
    }

    /** @return \WP_REST_Response|\WP_Error */
    public function progress( string $run_uuid, int $user_id ) {
        if ( ! $this->user_can_manage_bulk() ) {
            return Messages::error( 'forbidden', 403 );
        }
		$run_uuid = strtolower( trim( $run_uuid ) );
		if ( $user_id <= 0 || ! wp_is_uuid( $run_uuid ) ) {
			return Messages::error( 'invalid_request', 400 );
		}
        $run = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
        if ( empty( $run ) ) {
            return Messages::error( 'not_found', 404 );
        }
        $run_id = (int) $run['id'];
        $counts = $this->repo->count_items_by_status( $run_id, $user_id );
		$visible_statuses = [ 'pending', 'starting', 'generating', 'completed', 'partial', 'failed', 'skipped', 'uncertain' ];
		$stored_items = $this->repo->list_items_for_progress( $run_id, $user_id, $visible_statuses, self::MAX_RUN_ITEMS );
		$result_factory = new Workflow_Response_Factory();
		$items = [];
		foreach ( $stored_items as $item ) {
			$projected = $this->project_bulk_progress_item(
				$item,
				(string) ( $run['workflow'] ?? '' ),
				(string) $run['run_uuid'],
				$user_id,
				$result_factory
			);
			if ( ! empty( $projected ) ) {
				$items[] = $projected;
			}
		}
		$generating = $this->repo->get_generating_item( $run_id, $user_id );
		$current = is_array( $generating )
			? $this->project_bulk_progress_item(
				$generating,
				(string) ( $run['workflow'] ?? '' ),
				(string) $run['run_uuid'],
				$user_id,
				$result_factory
			)
			: null;

        return rest_ensure_response( [
            'run' => [
					'runUuid'        => (string) $run['run_uuid'],
					'workflow'       => (string) ( $run['workflow'] ?? '' ),
					'status'         => $this->public_bulk_run_status( (string) ( $run['status'] ?? '' ) ),
					'statusReason'   => sanitize_key( (string) ( $run['status_reason'] ?? '' ) ),
					'lastHeartbeatAt'=> $this->mysql_timestamp( $run['last_heartbeat_at'] ?? null ),
					'counts'         => $this->public_bulk_counts( $counts, (int) $run['selected_count'] ),
					'selectedCount'  => (int) $run['selected_count'],
            ],
			'items'   => $items,
			'current' => $current,
        ] );
    }

	/** @return array<string, mixed> */
	private function project_bulk_progress_item(
		array $item,
		string $workflow,
		string $run_uuid,
		int $user_id,
		Workflow_Response_Factory $result_factory
	): array {
		$identity = $this->bulk_item_identity_projection( $item, $workflow );
		if ( empty( $identity ) ) {
			return [];
		}
		$command_draft = $this->bulk_item_command_draft( $item );
		$expected = is_array( $command_draft ) ? $this->bulk_item_expected_output_count( $command_draft ) : 0;
		$attachment_ids = $this->decode_attachment_ids( $item['attachment_ids'] ?? [] );
		$attachments = array_map(
			static fn( int $attachment_id ): array => [
				'attachmentId' => $attachment_id,
				'fullUrl'      => Media_Saver::get_attachment_view_url( $attachment_id, 'full' ),
				'thumbUrl'     => Media_Saver::get_attachment_view_url( $attachment_id, 'medium_large' ),
			],
			$attachment_ids
		);
		$status = $this->public_bulk_item_status( (string) ( $item['status'] ?? '' ) );
		$completed_outputs = count( $attachment_ids );
		$terminal = in_array( $status, [ 'completed', 'partial', 'failed', 'skipped', 'uncertain' ], true );
		$target = (array) $identity['targetIdentity'];
		$result = in_array( $status, [ 'completed', 'partial' ], true )
			? $result_factory->present_owned_bulk_item( $run_uuid, (int) ( $item['id'] ?? 0 ), $user_id )
			: [];
		if ( 'removed' === (string) ( $result['status'] ?? '' ) ) {
			$status            = 'removed';
			$attachment_ids    = [];
			$attachments       = [];
			$completed_outputs = 0;
			$terminal          = true;
		}

		return [
			'id'                  => (int) ( $item['id'] ?? 0 ),
			'subjectType'         => (string) ( $item['subject_type'] ?? '' ),
			'subjectId'           => (int) ( $item['subject_id'] ?? 0 ),
			'status'              => $status,
			'expectedOutputCount' => $expected,
			'totalOutputs'        => $expected,
			'completedOutputs'    => $completed_outputs,
			'failedOutputs'       => $terminal && 'removed' !== $status ? max( 0, $expected - $completed_outputs ) : 0,
			'attachments'         => $attachments,
			'result'              => ! empty( $result ) ? $result : null,
			'runItemId'           => (string) $identity['runItemId'],
			'campaignGroupId'     => (string) $identity['campaignGroupId'],
			'itemOrdinal'         => (int) $identity['itemOrdinal'],
			'targetIdentity'      => $target,
			'updatedAt'           => $this->mysql_timestamp( $item['updated_at'] ?? null ),
			'finishedAt'          => $this->mysql_timestamp( $item['finished_at'] ?? null ),
			'errorCode'           => sanitize_key( (string) ( $item['error_code'] ?? '' ) ),
			'errorMessage'        => sanitize_text_field( (string) ( $item['error_message'] ?? '' ) ),
		];
	}

	/** @return array<string, int> */
	private function public_bulk_counts( array $counts, int $total ): array {
		return [
			'total'      => max( 0, $total ),
			'queued'     => (int) ( $counts['pending'] ?? 0 ),
			'starting'   => (int) ( $counts['starting'] ?? 0 ),
			'processing' => (int) ( $counts['generating'] ?? 0 ),
			'completed'  => (int) ( $counts['completed'] ?? 0 ),
			'partial'    => (int) ( $counts['partial'] ?? 0 ),
			'failed'     => (int) ( $counts['failed'] ?? 0 ),
			'skipped'    => (int) ( $counts['skipped'] ?? 0 ),
			'uncertain'  => (int) ( $counts['uncertain'] ?? 0 ),
		];
	}

	private function public_bulk_run_status( string $status ): string {
		return [
			'admitting' => 'starting',
			'running'   => 'processing',
		][ sanitize_key( $status ) ] ?? sanitize_key( $status );
	}

	private function public_bulk_item_status( string $status ): string {
		return [
			'pending'    => 'queued',
			'generating' => 'processing',
		][ sanitize_key( $status ) ] ?? sanitize_key( $status );
	}

	/** @param mixed $value Candidate value. */
	private function mysql_timestamp( $value ): int {
		$timestamp = is_string( $value ) && '' !== trim( $value ) ? strtotime( $value . ' UTC' ) : false;
		return false === $timestamp ? 0 : $timestamp;
	}

    /**
     * Project only a complete immutable item identity into a user-owned response.
     *
	 * The same persisted target/group contract is exposed during queued,
	 * processing, terminal, and recovered states.
     */
	private function bulk_item_identity_projection( array $item, string $workflow ): array {
        $run_item_id = strtolower( trim( (string) ( $item['run_item_uuid'] ?? '' ) ) );
        $campaign_group_id = strtolower( trim( (string) ( $item['campaign_group_uuid'] ?? '' ) ) );
        $item_ordinal = (int) ( $item['item_ordinal'] ?? 0 );
        $target = $item['target_snapshot'] ?? null;
        if ( is_string( $target ) ) {
            $decoded = json_decode( $target, true );
            $target = is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ? $decoded : null;
        }
        $target = Workflow_Item_Identity::normalize_target_snapshot( $target );
        if (
            ! wp_is_uuid( $run_item_id )
            || $item_ordinal <= 0
            || is_wp_error( $target )
            || ( '' !== $campaign_group_id && ! wp_is_uuid( $campaign_group_id ) )
            || ( 'campaign_set' === $workflow && ! wp_is_uuid( $campaign_group_id ) )
            || ( 'campaign_set' !== $workflow && '' !== $campaign_group_id )
        ) {
            return [];
        }

		return [
			'runItemId'       => $run_item_id,
			'campaignGroupId' => $campaign_group_id,
			'itemOrdinal'     => $item_ordinal,
			'targetIdentity'  => $target,
		];
	}

	/**
	 * Return the canonical per-item draft stored before worker admission.
	 *
	 * This projection is safe only in the owner-authorized progress response. It
	 * excludes request keys and worker metadata while preserving the output count.
	 */
	private function bulk_item_command_draft( array $item ): ?array {
		$encoded = trim( (string) ( $item['command_snapshot'] ?? '' ) );
		$command = '' !== $encoded ? json_decode( $encoded, true ) : null;
		if (
			! is_array( $command )
			|| fotogenic_ai_array_is_list( $command )
			|| JSON_ERROR_NONE !== json_last_error()
			|| 2 !== (int) ( $command['schemaVersion'] ?? 0 )
			|| ! is_array( $command['draft'] ?? null )
		) {
			return null;
		}

		$normalizer = new Workflow_Draft_Normalizer();
		$draft_object = $normalizer->rehydrate_normalized( $command['draft'] );
		if ( is_wp_error( $draft_object ) ) {
			return null;
		}
		$draft = $normalizer->normalize( $draft_object );
		if ( is_wp_error( $draft ) || 'bulk' !== (string) ( $draft['scope'] ?? '' ) ) {
			return null;
		}

		return $draft;
	}

	private function bulk_item_expected_output_count( array $draft ): int {
		$workflow = (string) ( $draft['workflow'] ?? '' );
		if ( 'increase_resolution' === $workflow ) {
			return 1;
		}
		if ( 'campaign_set' === $workflow ) {
			return max( 0, (int) ( $draft['sourcePolicy']['groups'][0]['recipe']['count'] ?? 0 ) );
		}

		return max( 0, (int) ( $draft['recipe']['count'] ?? 0 ) );
	}

	/**
	 * Project the exact owner-scoped identity needed to reopen a durable Bulk run.
	 *
	 * The run must match its canonical saved draft and accepted authority.
	 */
	private function bulk_run_recovery_identity( array $run ): array {
		$run_uuid = strtolower( trim( (string) ( $run['run_uuid'] ?? '' ) ) );
		$workflow = $this->get_run_workflow( $run );
		$selected_count = (int) ( $run['selected_count'] ?? 0 );
		$status = sanitize_key( (string) ( $run['status'] ?? '' ) );
		$status_map = [
			'admitting' => 'starting',
			'running'   => 'processing',
			'paused'    => 'paused',
			'stopping'  => 'stopping',
			'stopped'   => 'stopped',
			'completed' => 'completed',
			'partial'   => 'partial',
			'failed'    => 'failed',
			'uncertain' => 'uncertain',
		];
		if (
			! wp_is_uuid( $run_uuid )
			|| $selected_count < 1
			|| $selected_count > self::MAX_RUN_ITEMS
			|| ! isset( $status_map[ $status ] )
		) {
			return [];
		}

		$source_type = '';
		$expected_output_count = 0;
		$outputs_per_item = 0;
		$snapshot = $this->decode_filters_snapshot( $run['filters_snapshot'] ?? '' );
		$accepted_authority = is_array( $snapshot['acceptedAuthority'] ?? null )
			? $snapshot['acceptedAuthority']
			: [];
		if (
			1 !== (int) ( $accepted_authority['version'] ?? 0 )
			|| ! is_array( $snapshot['draft'] ?? null )
		) {
			return [];
		}
			$normalizer = new Workflow_Draft_Normalizer();
			$draft_object = $normalizer->rehydrate_normalized( $snapshot['draft'] );
			$draft = is_wp_error( $draft_object ) ? $draft_object : $normalizer->normalize( $draft_object );
			if (
				is_wp_error( $draft )
				|| 'bulk' !== (string) ( $draft['scope'] ?? '' )
				|| $workflow !== (string) ( $draft['workflow'] ?? '' )
			) {
				return [];
			}

			$source_type = sanitize_key( (string) ( $draft['sourceType'] ?? '' ) );
			$item_count = 'campaign_set' === $workflow
				? count( (array) ( $draft['sourcePolicy']['groups'] ?? [] ) )
				: count( (array) ( $draft['selection']['selectedIds'] ?? [] ) );
			$output_counts = 'campaign_set' === $workflow
				? array_map(
					static fn( array $group ): int => (int) ( $group['recipe']['count'] ?? 0 ),
					(array) ( $draft['sourcePolicy']['groups'] ?? [] )
				)
				: array_fill(
					0,
					$item_count,
					'increase_resolution' === $workflow ? 1 : (int) ( $draft['recipe']['count'] ?? 0 )
				);
			$expected_output_count = array_sum( $output_counts );
			if (
				! in_array( $source_type, [ 'product', 'media', 'post', 'page' ], true )
				|| $item_count !== $selected_count
				|| $expected_output_count < 1
				|| $expected_output_count > Workflow_Operational_Limits::MAX_RUN_OUTPUTS
				|| in_array( 0, $output_counts, true )
			) {
				return [];
			}
			if ( count( array_unique( $output_counts, SORT_NUMERIC ) ) === 1 ) {
				$outputs_per_item = (int) reset( $output_counts );
			}

		return [
			'version'             => 1,
			'workflow'            => $workflow,
			'sourceType'          => $source_type,
			'scope'               => 'bulk',
			'runUuid'             => $run_uuid,
			'status'              => $status_map[ $status ],
			'selectedCount'       => $selected_count,
			'expectedOutputCount' => $expected_output_count,
		] + ( $outputs_per_item > 0 ? [ 'outputsPerItem' => $outputs_per_item ] : [] );
	}

    /** @return \WP_REST_Response|\WP_Error */
    public function pause( string $run_uuid, int $user_id ) {
        $migration = Universal_Data_Migration::instance();
        if ( ! $migration->is_ready() ) {
            return $migration->readiness_error();
        }
        if ( ! $this->user_can_manage_bulk() ) {
            return Messages::error( 'forbidden', 403 );
        }
		return $this->update_run_status( $run_uuid, $user_id, 'running', 'paused', 'user_pause' );
    }

    /** @return \WP_REST_Response|\WP_Error */
    public function resume( string $run_uuid, int $user_id ) {
        $migration = Universal_Data_Migration::instance();
        if ( ! $migration->is_ready() ) {
            return $migration->readiness_error();
        }
        if ( ! $this->user_can_manage_bulk() ) {
            return Messages::error( 'forbidden', 403 );
        }
		$run_uuid = strtolower( trim( $run_uuid ) );
		if ( $user_id <= 0 || ! wp_is_uuid( $run_uuid ) ) {
			return Messages::error( 'invalid_request', 400 );
		}
        $saved_run = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
			if ( is_array( $saved_run ) && 'authority_changed' === (string) ( $saved_run['status_reason'] ?? '' ) ) {
			return new \WP_Error(
				'fotogenic_ai_bulk_review_required',
				__( 'The connected account, API environment, pricing, or configuration changed. Review the current Bulk request before resuming billable work.', 'fotogenic-ai' ),
				[ 'status' => 409, 'runUuid' => $run_uuid, 'state' => 'review_changed' ]
			);
		}
		$response = $this->update_run_status( $run_uuid, $user_id, 'paused', 'running', '' );
		if ( ! is_wp_error( $response ) ) {
            Bulk_Run_Runner::instance()->reset_resume_ramp( $run_uuid );
            if ( ! Bulk_Scheduler::instance()->schedule_tick( $run_uuid, $user_id, 2 ) ) {
                $run = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
                if ( is_array( $run ) ) {
                    $this->repo->update_run_if_status( (int) $run['id'], $user_id, 'running', [
                        'status'        => 'paused',
                        'status_reason' => 'scheduler_unavailable',
                        'last_error'    => 'Bulk resume could not be scheduled.',
                    ], (string) ( $run['workflow'] ?? '' ) );
                }
                return new \WP_Error(
                    'fotogenic_ai_bulk_scheduler_unavailable',
                    __( 'The bulk run could not be resumed because WordPress cron is unavailable.', 'fotogenic-ai' ),
                    [ 'status' => 503 ]
                );
            }
        }
        return $response;
    }

    /** @return \WP_REST_Response|\WP_Error */
    public function stop( string $run_uuid, int $user_id ) {
        $migration = Universal_Data_Migration::instance();
        if ( ! $migration->is_ready() ) {
            return $migration->readiness_error();
        }
        if ( ! $this->user_can_manage_bulk() ) {
            return Messages::error( 'forbidden', 403 );
        }
		$run_uuid = strtolower( trim( $run_uuid ) );
		if ( $user_id <= 0 || ! wp_is_uuid( $run_uuid ) ) {
			return Messages::error( 'invalid_request', 400 );
		}
        if ( ! $this->repo->acquire_admission_lock( $user_id ) ) {
            return $this->bulk_lifecycle_busy_error( $run_uuid );
        }
        try {
            return $this->stop_run_for_owner( $run_uuid, $user_id );
        } finally {
            $this->repo->release_admission_lock( $user_id );
        }
    }

    private function stop_run_for_owner( string $run_uuid, int $user_id ) {
        $run = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
        if ( empty( $run ) ) {
            return Messages::error( 'not_found', 404 );
        }
        $run_id = (int) $run['id'];
        $workflow = $this->get_run_workflow( $run );
        $run_status = sanitize_key( (string) ( $run['status'] ?? '' ) );
        if ( 'stopped' === $run_status ) {
			return $this->bulk_control_response( $run_uuid, 'stopped' );
        }
        if ( ! in_array( $run_status, [ 'running', 'paused', 'stopping' ], true ) ) {
            return $this->bulk_state_conflict( $run_uuid, $run_status );
        }
        if (
            'stopping' !== $run_status
            && ! $this->repo->update_run_if_status(
                $run_id,
                $user_id,
                $run_status,
                [
                    'status'        => 'stopping',
                    'status_reason' => 'user_stop',
                ],
                $workflow
            )
        ) {
            $run = $this->repo->get_run_by_id( $run_id, $user_id );
            $run_status = sanitize_key( (string) ( $run['status'] ?? '' ) );
            if ( ! in_array( $run_status, [ 'stopping', 'stopped' ], true ) ) {
                return $this->bulk_state_conflict( $run_uuid, $run_status );
            }
            if ( 'stopped' === $run_status ) {
				return $this->bulk_control_response( $run_uuid, 'stopped' );
            }
        }
        do {
            $stoppable_items = $this->repo->list_items_by_statuses( $run_id, $user_id, [ 'pending', 'starting' ], 100 );
            $stopped_items = 0;
            foreach ( $stoppable_items as $item ) {
                $item_id = (int) ( $item['id'] ?? 0 );
                if ( $item_id <= 0 ) {
                    continue;
                }
                if (
                    'starting' === (string) ( $item['status'] ?? '' )
                    && '' !== trim( (string) ( $item['command_snapshot'] ?? '' ) )
                    && '' !== trim( (string) ( $item['started_at'] ?? '' ) )
                ) {
                    // A universal dispatch marker exists. The runner must first
                    // reconcile its durable Single-request record; stop never
                    // assumes that an in-flight billable start was not sent.
                    continue;
                }
                $stopped = $this->repo->update_item_if_status( $item_id, $user_id, (string) ( $item['status'] ?? '' ), [
                    'status'        => 'skipped',
                    'error_code'    => 'STOPPED_BY_USER',
                    'error_message' => __( 'Skipped because run was stopped by user.', 'fotogenic-ai' ),
                    'finished_at'   => current_time( 'mysql', true ),
                ], $workflow );
                if ( $stopped ) {
                    ++$stopped_items;
                }
            }
        } while ( count( $stoppable_items ) >= 100 && $stopped_items > 0 );

        $counts = $this->repo->count_items_by_status( $run_id, $user_id );
        $completed = (int) ( $counts['completed'] ?? 0 );
        $partial = (int) ( $counts['partial'] ?? 0 );
        $failed = (int) ( $counts['failed'] ?? 0 );
        $skipped = (int) ( $counts['skipped'] ?? 0 );
        $uncertain = (int) ( $counts['uncertain'] ?? 0 );
        $starting = (int) ( $counts['starting'] ?? 0 );
        $generating = (int) ( $counts['generating'] ?? 0 );
		$public_status = 'stopping';
		if ( 0 === $starting && 0 === $generating ) {
            $this->repo->update_run_if_status( $run_id, $user_id, 'stopping', [
                'status' => 'stopped',
                'status_reason' => 'user_stop',
                'finished_at' => current_time( 'mysql', 1 ),
				'completed_count' => $completed + $partial,
				'failed_count' => $failed + $skipped + $uncertain,
            ], $workflow );
            Bulk_Scheduler::instance()->unschedule_tick( $run_uuid, $user_id );
			$public_status = 'stopped';
        } else {
            if ( ! Bulk_Scheduler::instance()->schedule_tick( $run_uuid, $user_id, 1 ) ) {
                $this->repo->update_run_if_status( $run_id, $user_id, 'stopping', [
                    'status_reason' => 'scheduler_unavailable',
                    'last_error'    => 'Bulk stop reconciliation could not be scheduled.',
                ], (string) ( $run['workflow'] ?? '' ) );
            }
        }
        if ( Logger::instance()->enabled() ) {
            Logger::instance()->info( 'bulk_run_stopped', [
                'run_id' => 'bulk:' . $run_uuid,
                'bulk_run_uuid' => $run_uuid,
                'user_id' => get_current_user_id(),
                'owner_user_id' => $user_id,
            ] );
        }
		return $this->bulk_control_response( $run_uuid, $public_status );
    }

    /** @return \WP_REST_Response|\WP_Error */
    public function active( int $user_id ) {
        if ( ! $this->user_can_manage_bulk() ) {
            return Messages::error( 'forbidden', 403 );
        }
		if ( $user_id <= 0 ) {
			return Messages::error( 'invalid_request', 400 );
		}
        $run = $this->get_active_run( $user_id );
        if ( empty( $run ) ) {
            return rest_ensure_response( null );
        }
        return rest_ensure_response( [
			'runUuid'          => (string) ( $run['run_uuid'] ?? '' ),
			'workflow'         => (string) ( $run['workflow'] ?? '' ),
			'status'           => $this->public_bulk_run_status( (string) ( $run['status'] ?? '' ) ),
			'statusReason'     => sanitize_key( (string) ( $run['status_reason'] ?? '' ) ),
			'recoveryIdentity' => $this->bulk_run_recovery_identity( $run ),
        ] );
    }

    /** @return \WP_REST_Response|\WP_Error */
    private function update_run_status( string $run_uuid, int $user_id, string $expected_status, string $status, string $reason ) {
		$run_uuid = strtolower( trim( $run_uuid ) );
		if ( $user_id <= 0 || ! wp_is_uuid( $run_uuid ) ) {
			return Messages::error( 'invalid_request', 400 );
		}
        $run = $this->repo->get_run_by_uuid( $run_uuid, $user_id );
        if ( empty( $run ) ) {
            return Messages::error( 'not_found', 404 );
        }
        $current_status = sanitize_key( (string) ( $run['status'] ?? '' ) );
        if ( $status === $current_status ) {
			return $this->bulk_control_response( $run_uuid, $this->public_bulk_run_status( $current_status ) );
        }
        if ( $expected_status !== $current_status ) {
            return $this->bulk_state_conflict( $run_uuid, $current_status );
        }
        $run_id = (int) $run['id'];
        $payload = [
			'status'        => $status,
			'status_reason' => $reason,
		];
        if ( ! $this->repo->update_run_if_status( $run_id, $user_id, $expected_status, $payload, $this->get_run_workflow( $run ) ) ) {
            $stored = $this->repo->get_run_by_id( $run_id, $user_id );
            return $this->bulk_state_conflict( $run_uuid, sanitize_key( (string) ( $stored['status'] ?? '' ) ) );
        }
        if ( Logger::instance()->enabled() ) {
            Logger::instance()->info( 'bulk_run_status_updated', [
                'run_id' => 'bulk:' . $run_uuid,
                'bulk_run_uuid' => $run_uuid,
                'status' => $status,
                'status_reason' => $reason,
                'actor_user_id' => get_current_user_id(),
                'owner_user_id' => $user_id,
            ] );
        }
		return $this->bulk_control_response( $run_uuid, $this->public_bulk_run_status( $status ) );
    }

	private function bulk_control_response( string $run_uuid, string $status ): \WP_REST_Response {
		return rest_ensure_response(
			[
				'runUuid' => $run_uuid,
				'status'  => $status,
			]
		);
	}

    private function bulk_state_conflict( string $run_uuid, string $status ): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_bulk_state_conflict',
            __( 'The bulk run changed state before this action could be applied.', 'fotogenic-ai' ),
            [
                'status'     => 409,
				'runUuid'   => $run_uuid,
				'runStatus' => $this->public_bulk_run_status( $status ),
            ]
        );
    }

    private function bulk_lifecycle_busy_error( string $run_uuid ): \WP_Error {
        return new \WP_Error(
            'fotogenic_ai_bulk_lifecycle_busy',
            __( 'Another bulk lifecycle change is in progress. Refresh the run status before retrying.', 'fotogenic-ai' ),
            [
                'status'   => 409,
				'runUuid' => $run_uuid,
            ]
        );
    }

    private function decode_filters_snapshot( $raw ): array {
        if ( is_string( $raw ) && $raw !== '' ) {
            $decoded = json_decode( $raw, true );
            if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
                return $decoded;
            }
        }
        return is_array( $raw ) ? $raw : [];
    }

    /**
     * @param mixed $value JSON or attachment ID list.
     */
    private function decode_attachment_ids( $value ): array {
        if ( is_string( $value ) ) {
            $decoded = json_decode( $value, true );
            $value = is_array( $decoded ) ? $decoded : [];
        }
        return is_array( $value )
            ? array_values( array_unique( array_filter( array_map( 'absint', $value ) ) ) )
            : [];
    }

    /**
     * @param mixed $value JSON or job ID list.
     */
    private function decode_job_ids( $value ): array {
        if ( is_string( $value ) ) {
            $decoded = json_decode( $value, true );
            $value = is_array( $decoded ) ? $decoded : [ $value ];
        }
        if ( ! is_array( $value ) ) {
            return [];
        }
        return array_values(
            array_unique(
                array_filter(
                    array_map(
                        static fn( $job_id ): string => trim( (string) $job_id ),
                        $value
                    )
                )
            )
        );
    }

    private function get_run_workflow( array $run ): string {
        $workflow = sanitize_key( (string) ( $run['workflow'] ?? '' ) );
        return in_array( $workflow, [ 'product_images', 'campaign_set', 'increase_resolution' ], true )
            ? $workflow
	            : '';
    }

    private function user_can_manage_bulk(): bool {
        return fotogenic_ai_user_can_access();
    }

    private static function normalize_products_search_query( string $query ): string {
        return preg_replace( '/[\x{2018}\x{2019}\x{02BC}]/u', "'", $query ) ?? $query;
    }

    private function normalize_category_filter( array $categories ): array {
        $ids = array_values( array_filter( array_map( 'absint', $categories ) ) );
        if ( empty( $ids ) ) {
            return array_values( array_filter( array_map( 'sanitize_text_field', $categories ) ) );
        }
        $terms = get_terms( [
            'taxonomy' => 'product_cat',
            'include' => $ids,
            'fields' => 'slugs',
            'hide_empty' => false,
        ] );
        if ( is_wp_error( $terms ) ) {
            return [];
        }
        return array_values( array_filter( array_map( 'sanitize_text_field', (array) $terms ) ) );
    }

    private function get_active_run( int $user_id ): array {
        $this->repo->ensure_tables();
        if ( $user_id <= 0 ) {
            return [];
        }
        global $wpdb;
        $table = $wpdb->prefix . 'fotogenic_ai_bulk_runs';
        $row = $wpdb->get_row(
            $wpdb->prepare(
	                "SELECT id, run_uuid, user_id, workflow, status, status_reason,
	                    filters_snapshot, selected_count,
                    started_at, last_error, last_heartbeat_at
                FROM {$table}
                WHERE user_id = %d AND status IN (%s,%s,%s,%s) ORDER BY id DESC LIMIT %d",
                $user_id,
                'admitting',
                'running',
                'paused',
                'stopping',
                1
            ),
            ARRAY_A
        );
        return is_array( $row ) ? $row : [];
    }

    /**
     * Run bounded housekeeping when the lazy gate is due and keep a WP-Cron
     * continuation scheduled for sites without status traffic.
     */
    private function run_bulk_housekeeping(): void {
        $now = time();
        $state = $this->get_bulk_housekeeping_state();
        $next_at = max( 0, (int) ( $state['next_at'] ?? 0 ) );
        if ( $next_at > $now ) {
            $this->schedule_bulk_housekeeping( $next_at );
            return;
        }

        $lock_token = Background_Lock::instance()->acquire_option(
            self::HOUSEKEEPING_LOCK_OPTION,
            self::HOUSEKEEPING_LOCK_TTL_SECONDS
        );
        if ( false === $lock_token ) {
            $this->schedule_bulk_housekeeping( $now + self::HOUSEKEEPING_CONTINUATION_SECONDS );
            return;
        }

        try {
            $state = $this->get_bulk_housekeeping_state();
            $next_at = max( 0, (int) ( $state['next_at'] ?? 0 ) );
            if ( $next_at > $now ) {
                $this->schedule_bulk_housekeeping( $next_at );
                return;
            }

            $retention_cursor = max( 0, (int) ( $state['retention_cursor'] ?? 0 ) );
            $fallback_next_at = $now + self::HOUSEKEEPING_INTERVAL_SECONDS;
			$this->set_bulk_housekeeping_state( $fallback_next_at, $retention_cursor );
            $this->schedule_bulk_housekeeping( $fallback_next_at );

            $retention = $this->repo->delete_expired_terminal_runs(
                $now - self::BULK_RUN_RETENTION_SECONDS,
                self::HOUSEKEEPING_BATCH_SIZE,
                $retention_cursor
            );
            $retention_cursor = max( 0, (int) ( $retention['next_cursor'] ?? 0 ) );
			$has_more = $retention_cursor > 0;
            $next_at = $now + ( $has_more ? self::HOUSEKEEPING_CONTINUATION_SECONDS : self::HOUSEKEEPING_INTERVAL_SECONDS );
			$this->set_bulk_housekeeping_state( $next_at, $retention_cursor );
            $this->schedule_bulk_housekeeping( $next_at );
        } catch ( \Throwable $error ) {
            if ( Logger::instance()->enabled() ) {
                Logger::instance()->warn( 'bulk_housekeeping_failed', [
                    'error_code'    => 'BULK_HOUSEKEEPING_FAILED',
                    'error_message' => $error->getMessage(),
                ] );
            }
        } finally {
            Background_Lock::instance()->release_option( self::HOUSEKEEPING_LOCK_OPTION, $lock_token );
        }
    }

    private function get_bulk_housekeeping_state(): array {
        $state = get_transient( self::HOUSEKEEPING_STATE_TRANSIENT );
        return is_array( $state ) ? $state : [];
    }

    private function set_bulk_housekeeping_state( int $next_at, int $retention_cursor ): void {
        set_transient(
            self::HOUSEKEEPING_STATE_TRANSIENT,
            [
                'next_at'          => max( 0, $next_at ),
                'retention_cursor' => max( 0, $retention_cursor ),
            ],
            self::HOUSEKEEPING_STATE_TTL_SECONDS
        );
    }

    private function schedule_bulk_housekeeping( int $timestamp ): void {
        if ( ! Background_Runtime_State::is_enabled() ) {
            wp_unschedule_hook( self::HOUSEKEEPING_HOOK );
            return;
        }
        $timestamp = max( time() + 1, $timestamp );
        $scheduled = wp_next_scheduled( self::HOUSEKEEPING_HOOK );
        if ( false !== $scheduled && (int) $scheduled <= $timestamp ) {
            return;
        }
        wp_schedule_single_event( $timestamp, self::HOUSEKEEPING_HOOK );
        if ( ! Background_Runtime_State::is_enabled() ) {
            wp_unschedule_hook( self::HOUSEKEEPING_HOOK );
        }
    }

}

add_action( Service_Bulk::HOUSEKEEPING_HOOK, [ Service_Bulk::class, 'handle_scheduled_housekeeping' ] );
