<?php

namespace Fotogenic_AI;

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

/**
 * Projects workflow context and durable state into browser-safe responses.
 */
final class Workflow_Response_Factory {
    private const MAX_ERROR_MESSAGE_LENGTH = 500;
    private const MAX_RESULT_ATTACHMENTS = 10;
    private const RESULT_ACTIONS = [ 'apply_featured', 'apply_gallery', 'apply_both', 'add_selected_to_gallery', 'add_all_to_gallery' ];

    public function build_context_response( string $workflow, string $request_id, array $context, array $metadata, string $status ): array {
        $response = [
            'status'              => $status,
            'workflow'            => $workflow,
            'requestId'           => $request_id,
            'runId'               => '',
            'jobIds'              => [],
            'sourceType'          => (string) ( $context['run_target'] ?? '' ),
            'subjectId'           => (int) ( $context['subject_id'] ?? 0 ),
            'sourceAttachmentId'  => (int) ( $metadata['source_attachment_id'] ?? $context['source_attachment_id'] ?? 0 ),
            'expectedOutputCount' => max( 0, (int) ( $metadata['expected_output_count'] ?? 0 ) ),
            'estimatedCost'       => max( 0, (float) ( $metadata['estimated_cost'] ?? 0 ) ),
        ];
        return array_merge( $response, $this->item_identity_projection( array_merge( $context, $metadata ) ) );
    }

    public function build_run_response( array $row, array $record, string $status ): array {
        $job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
		$attachments_map = Generate_Result_Authority::validated_attachment_map( $row );
        $attachment_ids = array_values( array_unique( array_map( 'intval', array_values( $attachments_map ) ) ) );
        $attachments = $this->build_attachments( $job_ids, $attachments_map );

        $expected_count = max( 0, (int) ( $row['expected_output_count'] ?? 0 ) );
        $response = [
            'status'              => $status,
            'workflow'            => (string) $record['workflow'],
            'requestId'           => (string) $record['request_id'],
            'runId'               => (string) ( $row['run_id'] ?? '' ),
            'jobIds'              => $job_ids,
            'sourceType'          => (string) ( $row['run_target'] ?? '' ),
            'subjectId'           => (int) ( $row['subject_id'] ?? 0 ),
            'sourceAttachmentId'  => (int) ( $row['source_attachment_id'] ?? 0 ),
            'expectedOutputCount' => $expected_count,
            'estimatedCost'       => max( 0, (float) ( $row['estimated_cost'] ?? 0 ) ),
            'acceptedOutputCount' => $expected_count,
            'attachmentsMap'      => $attachments_map,
            'attachmentIds'       => $attachment_ids,
            'attachments'         => $attachments,
			'errorCode'           => substr( sanitize_key( (string) ( $row['error_code'] ?? '' ) ), 0, 64 ),
            'errorMessage'        => $this->truncate_text( sanitize_text_field( (string) ( $row['error_message'] ?? '' ) ), self::MAX_ERROR_MESSAGE_LENGTH ),
        ];
        return array_merge( $response, $this->item_identity_projection( $row ) );
    }

    public function sanitize_stored_response( array $record, string $state ): array {
        $stored = isset( $record['response'] ) && is_array( $record['response'] ) ? $record['response'] : [];
        $job_ids = $this->normalize_job_ids( $stored['jobIds'] ?? [] );
        $workflow = sanitize_key( (string) ( $record['workflow'] ?? '' ) );
		$authority_row = $this->stored_authority_row( $record, $stored, $job_ids, $workflow );
		$attachments_map = Generate_Result_Authority::validated_attachment_map( $authority_row );
		$attachment_ids = Generate_Output_Map::attachment_ids( $attachments_map );
        $target = sanitize_key( (string) ( $stored['sourceType'] ?? '' ) );
        $allowed_targets = 'product_images' === $workflow
            ? [ 'product', 'media', 'post', 'page' ]
            : [ 'product', 'media' ];
        if ( ! in_array( $target, $allowed_targets, true ) ) {
            $target = '';
        }
        $response = [
            'status'              => $state,
            'workflow'            => (string) $record['workflow'],
            'requestId'           => (string) $record['request_id'],
            'runId'               => substr( sanitize_text_field( (string) ( $record['run_id'] ?? $stored['runId'] ?? '' ) ), 0, 64 ),
            'jobIds'              => $job_ids,
            'sourceType'          => $target,
            'subjectId'           => max( 0, (int) ( $stored['subjectId'] ?? 0 ) ),
            'sourceAttachmentId'  => max( 0, (int) ( $stored['sourceAttachmentId'] ?? 0 ) ),
            'expectedOutputCount' => max( 0, (int) ( $stored['expectedOutputCount'] ?? 0 ) ),
            'estimatedCost'       => max( 0, (float) ( $stored['estimatedCost'] ?? 0 ) ),
            'acceptedOutputCount' => max( 0, (int) ( $stored['acceptedOutputCount'] ?? $stored['expectedOutputCount'] ?? 0 ) ),
            'attachmentsMap'      => $attachments_map,
            'attachmentIds'       => $attachment_ids,
            'attachments'         => $this->build_attachments( $job_ids, $attachments_map ),
            'errorCode'           => substr( sanitize_key( (string) ( $stored['errorCode'] ?? '' ) ), 0, 64 ),
            'errorMessage'        => $this->truncate_text( sanitize_text_field( (string) ( $stored['errorMessage'] ?? '' ) ), self::MAX_ERROR_MESSAGE_LENGTH ),
        ];
        return array_merge( $response, $this->item_identity_projection( $stored ) );
    }

    /**
     * Present one durable run without trusting browser-selected actions or URLs.
     *
     * @return array<string, mixed>
     */
    public function present_owned_run( array $row, int $user_id ): array {
        if ( ! $this->row_is_owned( $row, $user_id ) ) {
            return [];
        }

		$workflow       = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$job_ids        = $this->normalize_job_ids( $row['job_ids'] ?? [] );
		$admitted_map   = Generate_Result_Authority::admitted_attachment_map( $row );
		$attachments_map = Generate_Result_Authority::validated_attachment_map( $row );
        $attachment_ids = $this->ordered_attachment_ids( $job_ids, $attachments_map );
		$persisted_attachment_ids = $this->ordered_attachment_ids( $job_ids, $admitted_map );
        $attachments = [];
        $all_mutable = ! empty( $attachment_ids );
        foreach ( array_slice( $attachment_ids, 0, self::MAX_RESULT_ATTACHMENTS ) as $attachment_id ) {
            $presented = $this->present_attachment( $attachment_id, $row, $user_id, true );
            if ( empty( $presented ) ) {
                $all_mutable = false;
                continue;
            }
            if ( empty( $presented['provenanceValid'] ) ) {
                $all_mutable = false;
                continue;
            }
            unset( $presented['provenanceValid'] );
            $attachments[] = $presented;
        }

        $target         = sanitize_key( (string) ( $row['run_target'] ?? '' ) );
        $scope          = '' === trim( (string) ( $row['bulk_run_uuid'] ?? '' ) ) ? 'single' : 'bulk';
		$requested      = max( 0, (int) ( $row['expected_output_count'] ?? 0 ) );
		$saved          = count( $attachments );
		$terminal_counts = 'campaign_set' === $workflow
			? $this->campaign_terminal_output_counts( $row['finalize_meta'] ?? [], $job_ids, $requested )
			: [];
		$completed_outputs = ! empty( $terminal_counts ) ? $terminal_counts['completed'] : $saved;
		$failed_outputs    = ! empty( $terminal_counts ) ? $terminal_counts['failed'] : max( 0, $requested - $completed_outputs );
        $status         = sanitize_key( (string) ( $row['final_status'] ?? '' ) );
        $result_terminal = in_array( $status, [ 'completed', 'partial', 'stopped' ], true );
        $attachments_removed = 'single' === $scope
            && 'completed' === $status
            && $requested > 0
			&& count( $persisted_attachment_ids ) === $requested
			&& $this->persisted_outputs_removed( $persisted_attachment_ids );
        $partial        = ! $attachments_removed
            && $result_terminal
			&& ( 'partial' === $status || ( $requested > 0 && $saved < $requested ) );
		$available      = ! $attachments_removed && $all_mutable ? $this->available_actions( $row, $user_id, $attachments ) : [];
		$presented_attachment_ids = array_values( array_map( 'absint', array_column( $attachments, 'attachmentId' ) ) );
		$delivery       = Result_Delivery_Coordinator::presentation( $row, $presented_attachment_ids );
		$unapplied      = $available;
		$delivery_retry_available = false;
		if ( ! empty( $delivery['appliedAction'] ) || ! empty( $delivery['deliveryPending'] ) ) {
			$available = [];
		} elseif ( ! empty( $delivery['deliveryFailed'] ) ) {
			$retry_action = sanitize_key( (string) ( $delivery['retryAction'] ?? '' ) );
			$delivery_retry_available = $this->delivery_retry_is_available( $row, $retry_action, $user_id, $attachments, $available, false );
			if ( $delivery_retry_available ) {
				$available = [ $retry_action ];
				$unapplied = $available;
			}
		}
		$error_code    = substr( sanitize_key( (string) ( $row['error_code'] ?? '' ) ), 0, 64 );
		$error_message = $this->truncate_text( sanitize_text_field( (string) ( $row['error_message'] ?? '' ) ), self::MAX_ERROR_MESSAGE_LENGTH );
		$message        = '';
        if ( $attachments_removed ) {
            $message = __( 'The generated attachment was deleted from the Media Library.', 'fotogenic-ai' );
		} elseif ( ! empty( $delivery['deliveryFailed'] ) ) {
			$message = (string) ( $delivery['message'] ?? '' );
		} elseif ( $partial ) {
            $message = __( 'Some requested outputs were not saved. Available images are shown.', 'fotogenic-ai' );
		} elseif ( in_array( $status, [ 'failed', 'timeout', 'stale' ], true ) && '' !== $error_message ) {
			$message = $error_message;
        }

        $source              = $this->present_source( $row, $user_id );
        $sources             = $this->present_sources( $row, $user_id );
        $current_product     = $this->present_current_product( $row, $user_id );
        $target_links        = $this->present_target_links( $row, $user_id );
        $identity_projection = $this->item_identity_projection( $row );
        $quality             = strtoupper( sanitize_text_field( (string) ( $row['quality'] ?? '' ) ) );
		$quality             = 'increase_resolution' === $workflow
			? '2K'
			: ( Workflow_Quality_Catalog::supports( $workflow, $quality ) ? $quality : '' );
		$delivery_label      = (string) ( $delivery['deliveryLabel'] ?? $this->delivery_label( $workflow, $target ) );
		$group_status        = $attachments_removed
			? 'removed'
			: ( empty( $attachments ) ? 'failed' : ( $partial || ! empty( $delivery['deliveryFailed'] ) ? 'partial' : 'completed' ) );
        $groups              = [];
        if ( 'campaign_set' === $workflow ) {
            $groups[] = [
                'key'                  => substr( sanitize_text_field( (string) ( $row['run_id'] ?? '' ) ), 0, 64 ),
                'label'                => __( 'Campaign images', 'fotogenic-ai' ),
                'status'               => $group_status,
                'message'              => $message,
				'attachments'          => $attachments,
				'availableActions'     => $available,
				'unappliedActions'     => $unapplied,
				'appliedAction'        => (string) ( $delivery['appliedAction'] ?? '' ),
				'appliedAttachmentId'  => absint( $delivery['appliedAttachmentId'] ?? 0 ),
				'appliedAttachmentIds' => array_values( array_map( 'absint', (array) ( $delivery['appliedAttachmentIds'] ?? [] ) ) ),
				'deliveryPending'       => ! empty( $delivery['deliveryPending'] ),
				'deliveryFailed'        => ! empty( $delivery['deliveryFailed'] ),
				'deliveryRetryAvailable'=> $delivery_retry_available,
				'requestedOutputCount' => $requested,
				'savedOutputCount'     => $saved,
				'completedOutputCount' => $completed_outputs,
				'failedOutputCount'    => $failed_outputs,
                'source'               => $source,
                'sources'              => $sources,
                'sourceMissing'        => ! empty( $source['missing'] ),
                'currentProduct'       => $current_product,
                'currentProductMissing' => ! empty( $current_product['missing'] ),
                'targetIdentity'       => $identity_projection['targetIdentity'] ?? null,
                'targetLinks'          => $target_links,
                'quality'              => $quality,
                'deliveryLabel'        => $delivery_label,
            ];
        }

        $response = [
            'workflow'             => $workflow,
            'sourceType'           => $target,
            'scope'                => $scope,
            'requestId'            => substr( sanitize_text_field( (string) ( $row['request_id'] ?? '' ) ), 0, 64 ),
            'runId'                => substr( sanitize_text_field( (string) ( $row['run_id'] ?? '' ) ), 0, 64 ),
            'subjectId'            => max( 0, (int) ( $row['subject_id'] ?? 0 ) ),
            'sourceAttachmentId'   => max( 0, (int) ( $row['source_attachment_id'] ?? 0 ) ),
            'source'               => $source,
            'sources'              => $sources,
            'sourceMissing'        => ! empty( $source['missing'] ),
            'currentProduct'       => $current_product,
            'currentProductMissing' => ! empty( $current_product['missing'] ),
            'targetLinks'           => $target_links,
            'quality'               => $quality,
            'deliveryLabel'         => $delivery_label,
			'attachments'          => $attachments,
			'availableActions'     => $available,
			'unappliedActions'     => $unapplied,
			'appliedAction'        => (string) ( $delivery['appliedAction'] ?? '' ),
			'appliedAttachmentId'  => absint( $delivery['appliedAttachmentId'] ?? 0 ),
			'appliedAttachmentIds' => array_values( array_map( 'absint', (array) ( $delivery['appliedAttachmentIds'] ?? [] ) ) ),
			'deliveryPending'       => ! empty( $delivery['deliveryPending'] ),
			'deliveryFailed'        => ! empty( $delivery['deliveryFailed'] ),
			'deliveryRetryAvailable'=> $delivery_retry_available,
            'groups'               => $groups,
			'requestedOutputCount' => $requested,
			'savedOutputCount'     => $saved,
			'completedOutputCount' => $completed_outputs,
			'failedOutputCount'    => $failed_outputs,
            'partial'              => $partial,
            'message'              => $message,
			'errorCode'            => $error_code,
			'errorMessage'         => $error_message,
        ];
        if ( $attachments_removed ) {
            $response['status'] = 'removed';
        }
        return array_merge( $response, $identity_projection );
    }

	/** @return array{completed:int,failed:int} */
	private function campaign_terminal_output_counts( $value, array $job_ids, int $expected_requested ): array {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			$value = is_array( $decoded ) ? $decoded : [];
		}
		if ( ! is_array( $value ) || $expected_requested < 1 ) {
			return [];
		}
		$stored = $value['__terminal_output_counts'] ?? [];
		if ( ! is_array( $stored ) ) {
			return [];
		}
		$requested = 0;
		$completed = 0;
		$failed    = 0;
		foreach ( $job_ids as $job_id ) {
			$counts = $stored[ $job_id ] ?? null;
			if ( ! is_array( $counts ) || ! is_int( $counts['requested'] ?? null ) || ! is_int( $counts['completed'] ?? null ) || ! is_int( $counts['failed'] ?? null ) ) {
				continue;
			}
			$job_requested = $counts['requested'];
			$job_completed = $counts['completed'];
			$job_failed    = $counts['failed'];
			if ( $job_requested < 1 || $job_completed < 0 || $job_failed < 0 || $job_completed + $job_failed !== $job_requested ) {
				return [];
			}
			$requested += $job_requested;
			$completed += $job_completed;
			$failed += $job_failed;
		}
		if ( $requested !== $expected_requested || $completed + $failed !== $requested ) {
			return [];
		}
		return [ 'completed' => $completed, 'failed' => $failed ];
	}

    /**
     * Present one terminal universal Bulk item from its durable run graph.
     *
     * The browser supplies neither the Single run row nor attachment URLs. This
     * method resolves the Bulk run/item and requires their exact Logger identity
     * before reusing the common result projection.
     *
     * @return array<string, mixed>
     */
    public function present_owned_bulk_item( string $bulk_run_uuid, int $bulk_item_id, int $user_id ): array {
        $resolved = $this->resolve_bulk_identity( $bulk_run_uuid, $bulk_item_id, $user_id, true );
        if ( is_wp_error( $resolved ) ) {
            return [];
        }

        $presented = $this->present_owned_run( $resolved['row'], $user_id );
        if ( empty( $presented ) ) {
            return [];
        }
		$available = ! empty( $resolved['attachments_exact'] )
			? $this->available_actions( $resolved['row'], $user_id, (array) ( $presented['attachments'] ?? [] ), true )
			: [];
		$unapplied = $available;
		$presented_attachment_ids = array_values(
			array_map( 'absint', array_column( (array) ( $presented['attachments'] ?? [] ), 'attachmentId' ) )
		);
		$delivery = Result_Delivery_Coordinator::presentation( $resolved['row'], $presented_attachment_ids );
		$delivery_retry_available = false;
		if ( ! empty( $delivery['appliedAction'] ) || ! empty( $delivery['deliveryPending'] ) ) {
			$available = [];
		} elseif ( ! empty( $delivery['deliveryFailed'] ) ) {
			$retry_action = sanitize_key( (string) ( $delivery['retryAction'] ?? '' ) );
			$retry_available = $this->delivery_retry_is_available(
				$resolved['row'],
				$retry_action,
				$user_id,
				(array) ( $presented['attachments'] ?? [] ),
				$available,
				true
			);
			$delivery_retry_available = $retry_available;
			if ( $retry_available ) {
				$available = [ $retry_action ];
				$unapplied = $available;
			}
		}
        $presented['scope']            = 'bulk';
		$presented['bulkRunUuid']      = $bulk_run_uuid;
		$presented['bulkItemId']       = $bulk_item_id;
		$presented['availableActions'] = $available;
		$presented['unappliedActions'] = $unapplied;
		$presented['deliveryPending'] = ! empty( $delivery['deliveryPending'] );
		$presented['deliveryFailed'] = ! empty( $delivery['deliveryFailed'] );
		$presented['deliveryRetryAvailable'] = $delivery_retry_available;
        $attachments_removed = ! empty( $resolved['attachments_removed'] );
        $presented['status'] = $attachments_removed
            ? 'removed'
            : (string) $resolved['item']['status'];
        if ( $attachments_removed ) {
            $presented['partial']          = false;
			$presented['message']          = __( 'The generated attachment was deleted from the Media Library.', 'fotogenic-ai' );
			$presented['availableActions'] = [];
			$presented['unappliedActions'] = [];
        }
		foreach ( (array) ( $presented['groups'] ?? [] ) as $index => $group ) {
			$presented['groups'][ $index ]['status']           = $attachments_removed
				? 'removed'
				: ( ! empty( $delivery['deliveryFailed'] ) ? 'partial' : (string) ( $group['status'] ?? '' ) );
			$presented['groups'][ $index ]['message']          = $attachments_removed ? $presented['message'] : (string) ( $group['message'] ?? '' );
			$presented['groups'][ $index ]['availableActions'] = $attachments_removed ? [] : $available;
			$presented['groups'][ $index ]['unappliedActions'] = $attachments_removed ? [] : $unapplied;
			$presented['groups'][ $index ]['deliveryPending'] = ! $attachments_removed && ! empty( $delivery['deliveryPending'] );
			$presented['groups'][ $index ]['deliveryFailed'] = ! $attachments_removed && ! empty( $delivery['deliveryFailed'] );
			$presented['groups'][ $index ]['deliveryRetryAvailable'] = ! $attachments_removed && $delivery_retry_available;
		}
        return $presented;
    }

    /**
     * Authorize a universal result mutation against durable run provenance.
     *
     * @return array{row: array, attachment_ids: int[]}|\WP_Error
     */
    public function authorize_mutation( array $identity, string $action, array $attachment_ids, int $user_id ) {
        $action = sanitize_key( $action );
        if ( ! in_array( $action, array_merge( self::RESULT_ACTIONS, [ 'delete' ] ), true ) ) {
            return $this->mutation_error( 'fotogenic_ai_result_action_invalid', 400 );
        }

        $run_id     = substr( sanitize_text_field( (string) ( $identity['runId'] ?? '' ) ), 0, 64 );
        $request_id = strtolower( trim( (string) ( $identity['requestId'] ?? '' ) ) );
        $workflow   = sanitize_key( (string) ( $identity['workflow'] ?? '' ) );
        $target     = sanitize_key( (string) ( $identity['sourceType'] ?? '' ) );
        $scope      = sanitize_key( (string) ( $identity['scope'] ?? '' ) );
        $subject_id = absint( $identity['subjectId'] ?? 0 );
        if (
            $user_id <= 0
            || '' === $run_id
            || ! wp_is_uuid( $request_id )
            || ! in_array( $workflow, Workflow_Request_Store::WORKFLOWS, true )
            || ! in_array( $target, [ 'product', 'media', 'post', 'page' ], true )
            || ! in_array( $scope, [ 'single', 'bulk' ], true )
            || $subject_id <= 0
        ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_invalid', 400 );
        }

        $bulk_authorized = false;
        if ( 'bulk' === $scope ) {
            $bulk_run_uuid = strtolower( trim( (string) ( $identity['bulkRunUuid'] ?? '' ) ) );
            $bulk_item_id  = is_int( $identity['bulkItemId'] ?? null ) ? $identity['bulkItemId'] : 0;
            $resolved      = $this->resolve_bulk_identity( $bulk_run_uuid, $bulk_item_id, $user_id, 'delete' === $action );
            if ( is_wp_error( $resolved ) ) {
                return $resolved;
            }
            $row = $resolved['row'];
            if (
                ! hash_equals( $run_id, (string) ( $row['run_id'] ?? '' ) )
                || ! hash_equals( $request_id, strtolower( (string) ( $row['request_id'] ?? '' ) ) )
                || $workflow !== sanitize_key( (string) ( $row['workflow'] ?? '' ) )
                || $target !== sanitize_key( (string) ( $row['run_target'] ?? '' ) )
                || $subject_id !== (int) ( $row['subject_id'] ?? 0 )
            ) {
                return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
            }
            $bulk_authorized = true;
        } else {
            $row = Logger::instance()->get_run_row( $run_id );
            if (
                ! $this->row_is_owned( $row, $user_id )
                || ! hash_equals( $request_id, strtolower( (string) ( $row['request_id'] ?? '' ) ) )
                || $workflow !== sanitize_key( (string) ( $row['workflow'] ?? '' ) )
                || $target !== sanitize_key( (string) ( $row['run_target'] ?? '' ) )
                || $subject_id !== (int) ( $row['subject_id'] ?? 0 )
                || '' !== trim( (string) ( $row['bulk_run_uuid'] ?? '' ) )
                || absint( $row['bulk_item_id'] ?? 0 ) > 0
            ) {
                return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
            }
        }

        if ( ! $this->mutation_item_identity_matches( $identity, $row ) ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
        }

        $attachment_ids = array_values( array_unique( array_filter( array_map( 'absint', $attachment_ids ) ) ) );
        if ( empty( $attachment_ids ) || count( $attachment_ids ) > self::MAX_RESULT_ATTACHMENTS ) {
            return $this->mutation_error( 'fotogenic_ai_result_attachments_invalid', 400 );
        }

        $job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
		$owned   = $this->ordered_attachment_ids(
			$job_ids,
			Generate_Result_Authority::validated_attachment_map( $row )
		);
        if ( ! empty( array_diff( $attachment_ids, $owned ) ) ) {
            return $this->mutation_error( 'fotogenic_ai_result_attachment_forbidden', 403 );
        }

        if ( 'delete' !== $action ) {
            $presented = $bulk_authorized
                ? $this->present_owned_bulk_item( (string) $identity['bulkRunUuid'], (int) $identity['bulkItemId'], $user_id )
                : $this->present_owned_run( $row, $user_id );
            if ( ! in_array( $action, (array) ( $presented['availableActions'] ?? [] ), true ) ) {
                return $this->mutation_error( 'fotogenic_ai_result_action_forbidden', 403 );
            }
            if ( 'add_all_to_gallery' === $action ) {
                $presented_ids = array_values( array_map( 'absint', wp_list_pluck( (array) ( $presented['attachments'] ?? [] ), 'attachmentId' ) ) );
                sort( $presented_ids );
                $requested_ids = $attachment_ids;
                sort( $requested_ids );
                if ( $presented_ids !== $requested_ids ) {
                    return $this->mutation_error( 'fotogenic_ai_result_attachments_invalid', 409 );
                }
			} elseif ( 'add_selected_to_gallery' !== $action && 1 !== count( $attachment_ids ) ) {
                return $this->mutation_error( 'fotogenic_ai_result_attachments_invalid', 400 );
            }
        }

        foreach ( $attachment_ids as $attachment_id ) {
            $post = get_post( $attachment_id );
            if ( ! $post ) {
                if ( 'delete' === $action ) {
                    continue;
                }
                return $this->mutation_error( 'fotogenic_ai_result_attachment_missing', 404 );
            }
            if ( ! wp_attachment_is_image( $attachment_id ) ) {
                return $this->mutation_error( 'fotogenic_ai_result_attachment_forbidden', 403 );
            }
            if ( 'delete' === $action && ! current_user_can( 'delete_post', $attachment_id ) ) {
                return $this->mutation_error( 'fotogenic_ai_result_action_forbidden', 403 );
            }
        }

        return [
            'row'            => $row,
            'attachment_ids' => $attachment_ids,
        ];
    }

    public function normalize_job_ids( $job_ids ): array {
        if ( is_string( $job_ids ) ) {
            $decoded = json_decode( $job_ids, true );
            $job_ids = is_array( $decoded ) ? $decoded : [ $job_ids ];
        }
        if ( ! is_array( $job_ids ) ) {
            return [];
        }
		$normalized = [];
		foreach ( $job_ids as $job_id ) {
			$job_id = Upstream_Identifier::normalize( $job_id );
			if ( '' !== $job_id ) {
				$normalized[] = $job_id;
            }
        }
        return array_values( array_unique( $normalized ) );
    }

    public function truncate_text( string $value, int $length ): string {
        return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $length ) : substr( $value, 0, $length );
    }

    private function build_attachments( array $job_ids, array $attachments_map ): array {
        $attachments = [];
        foreach ( $job_ids as $job_id ) {
			foreach ( Generate_Output_Map::for_job( $attachments_map, $job_id ) as $attachment_id ) {
				$attachment = [
					'jobId'        => $job_id,
					'attachmentId' => $attachment_id,
				];
				$url = '';
				if ( class_exists( Media_Saver::class ) ) {
					$url = (string) Media_Saver::get_attachment_view_url( $attachment_id, 'full' );
				} elseif ( function_exists( 'wp_get_attachment_url' ) ) {
					$url = (string) wp_get_attachment_url( $attachment_id );
				}
				if ( '' !== $url ) {
					$attachment['fullUrl'] = $url;
				}
				$attachments[] = $attachment;
			}
        }
        return $attachments;
    }

    /** @return string[] */
    private function available_actions( array $row, int $user_id, array $attachments, bool $bulk_authorized = false ): array {
        $is_bulk = '' !== trim( (string) ( $row['bulk_run_uuid'] ?? '' ) );
        if (
            $user_id <= 0
            || get_current_user_id() !== $user_id
            || empty( $attachments )
            || $is_bulk !== $bulk_authorized
            || ! wp_is_uuid( strtolower( trim( (string) ( $row['request_id'] ?? '' ) ) ) )
            || ! in_array( sanitize_key( (string) ( $row['final_status'] ?? '' ) ), [ 'completed', 'partial' ], true )
        ) {
            return [];
        }
        $workflow   = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
        $target     = sanitize_key( (string) ( $row['run_target'] ?? '' ) );
        $subject_id = absint( $row['subject_id'] ?? 0 );
        if ( $subject_id <= 0 || ! current_user_can( 'edit_post', $subject_id ) ) {
            return [];
        }
        $actions = [];
        if ( 'product' === $target ) {
			// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce maps this meta capability for exact Product and variation posts.
			if ( ! current_user_can( 'edit_product', $subject_id ) ) {
				return [];
			}
            $woo_available = function_exists( __NAMESPACE__ . '\\fotogenic_ai_woocommerce_available' )
                && fotogenic_ai_woocommerce_available();
            if ( ! $woo_available ) {
                return [];
            }
            $target_snapshot = $this->target_snapshot_from_row( $row );
            if ( is_wp_error( $target_snapshot ) ) {
				return [];
			}
			$capabilities = (array) $target_snapshot['destinationCapabilities'];
            if ( 'product_images' === $workflow && ! empty( $capabilities['setFeatured'] ) ) {
                $actions[] = 'apply_featured';
            }
            if ( 'product_images' === $workflow && ! empty( $capabilities['addGallery'] ) ) {
                $actions[] = 'apply_gallery';
            }
            if ( 'product_images' === $workflow && ! empty( $capabilities['setFeaturedAndGallery'] ) ) {
                $actions[] = 'apply_both';
            }
            if ( 'campaign_set' === $workflow ) {
                $actions = ! empty( $capabilities['addGallery'] )
					? [ 'add_selected_to_gallery' ]
                    : ( ! empty( $capabilities['setFeatured'] ) ? [ 'apply_featured' ] : [] );
            }
        }
        if ( 'product_images' === $workflow && in_array( $target, [ 'post', 'page' ], true ) ) {
			$target_snapshot = $this->target_snapshot_from_row( $row );
			$current_target  = get_post( $subject_id );
			if (
				is_wp_error( $target_snapshot )
				|| $target !== sanitize_key( (string) ( $target_snapshot['targetType'] ?? '' ) )
				|| $subject_id !== absint( $target_snapshot['targetId'] ?? 0 )
				|| empty( $target_snapshot['destinationCapabilities']['applyFeatured'] )
				|| ! is_object( $current_target )
				|| $target !== sanitize_key( (string) ( $current_target->post_type ?? '' ) )
			) {
				return [];
			}
            $actions = [ 'apply_featured' ];
        }
        if ( empty( $actions ) ) {
            return [];
        }

        $filtered = apply_filters(
            'fotogenic_ai_studio_result_actions',
            $actions,
            [
                'workflow'        => $workflow,
                'sourceType'      => $target,
                'scope'           => $bulk_authorized ? 'bulk' : 'single',
                'subjectId'       => $subject_id,
                'attachmentCount' => count( $attachments ),
            ]
        );
        if ( ! is_array( $filtered ) ) {
            return [];
        }
        $safe = [];
        foreach ( $filtered as $candidate ) {
            if ( ! is_string( $candidate ) ) {
                continue;
            }
            $candidate = sanitize_key( $candidate );
            if ( in_array( $candidate, $actions, true ) && ! in_array( $candidate, $safe, true ) ) {
                $safe[] = $candidate;
            }
        }
        return $safe;
    }

	private function delivery_retry_is_available(
		array $row,
		string $action,
		int $user_id,
		array $attachments,
		array $available_actions,
		bool $bulk_authorized
	): bool {
		$action = sanitize_key( $action );
		if ( ! in_array( $action, self::RESULT_ACTIONS, true ) || empty( $attachments ) ) {
			return false;
		}
		if ( in_array( $action, $available_actions, true ) ) {
			return true;
		}
		if ( 'add_all_to_gallery' !== $action ) {
			return false;
		}

		$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$target   = sanitize_key( (string) ( $row['run_target'] ?? '' ) );
		$subject_id = absint( $row['subject_id'] ?? 0 );
		$destination = $this->decode_object( $row['destination_snapshot'] ?? null );
		if (
			! in_array( $workflow, [ 'product_images', 'campaign_set' ], true )
			|| 'product' !== $target
			|| 'gallery_append' !== sanitize_key( (string) ( $destination['assignmentMode'] ?? '' ) )
			|| $user_id <= 0
			|| get_current_user_id() !== $user_id
			|| $subject_id <= 0
			|| ( '' !== trim( (string) ( $row['bulk_run_uuid'] ?? '' ) ) ) !== $bulk_authorized
			|| ! function_exists( __NAMESPACE__ . '\\fotogenic_ai_woocommerce_available' )
			|| ! fotogenic_ai_woocommerce_available()
		) {
			return false;
		}

		$target_snapshot = $this->target_snapshot_from_row( $row );
		if (
			is_wp_error( $target_snapshot )
			|| 'product' !== (string) ( $target_snapshot['targetType'] ?? '' )
			|| empty( $target_snapshot['destinationCapabilities']['addGallery'] )
		) {
			return false;
		}
		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce maps this meta capability for exact Products.
		return current_user_can( 'edit_product', $subject_id );
	}

    /** @return array<string, mixed> */
    private function item_identity_projection( array $source ): array {
        $run_item_uuid = strtolower( trim( (string) ( $source['run_item_uuid'] ?? $source['runItemId'] ?? '' ) ) );
        if ( ! wp_is_uuid( $run_item_uuid ) ) {
            return [];
        }
        $campaign_group_uuid = strtolower( trim( (string) ( $source['campaign_group_uuid'] ?? $source['campaignGroupId'] ?? '' ) ) );
        if ( ! wp_is_uuid( $campaign_group_uuid ) ) {
            $campaign_group_uuid = '';
        }
        $item_ordinal = absint( $source['item_ordinal'] ?? $source['itemOrdinal'] ?? 0 );
        if ( $item_ordinal <= 0 ) {
            return [];
        }
        $target_identity = $this->target_snapshot_from_row( $source );
        if ( is_wp_error( $target_identity ) ) {
            return [];
        }
		$target_identity['label'] = fotogenic_ai_plain_text_label( (string) $target_identity['label'] );
        return [
            'runItemId'       => $run_item_uuid,
            'campaignGroupId' => '' !== $campaign_group_uuid ? $campaign_group_uuid : null,
            'itemOrdinal'     => $item_ordinal,
            'targetIdentity'  => $target_identity,
        ];
    }

    private function mutation_item_identity_matches( array $identity, array $row ): bool {
        $expected = $this->item_identity_projection( $row );
        $actual_run_item = strtolower( trim( (string) ( $identity['runItemId'] ?? '' ) ) );
        if ( empty( $expected ) ) {
            return '' === $actual_run_item;
        }
        $actual_group = $identity['campaignGroupId'] ?? null;
        $actual_group = is_string( $actual_group ) ? strtolower( trim( $actual_group ) ) : null;
        $expected_target = Workflow_Item_Identity::target_hint( (array) ( $expected['targetIdentity'] ?? [] ) );
        $actual_target   = $identity['targetIdentity'] ?? null;
        return wp_is_uuid( $actual_run_item )
            && hash_equals( (string) $expected['runItemId'], $actual_run_item )
            && $expected['campaignGroupId'] === $actual_group
            && (int) $expected['itemOrdinal'] === (int) ( $identity['itemOrdinal'] ?? 0 )
            && is_array( $actual_target )
            && $expected_target === $actual_target;
    }

    /** @return array|\WP_Error */
    private function target_snapshot_from_row( array $source ) {
        $snapshot = $source['target_snapshot'] ?? $source['targetIdentity'] ?? null;
        if ( is_string( $snapshot ) ) {
            $decoded = json_decode( $snapshot, true );
            $snapshot = is_array( $decoded ) ? $decoded : null;
        }
        return Workflow_Item_Identity::normalize_target_snapshot( $snapshot );
    }

    /**
     * Resolve and cross-check every durable identity that authorizes one Bulk item.
     *
     * @return array{run:array,item:array,row:array,attachment_ids:int[],attachments_exact:bool,attachments_removed:bool}|\WP_Error
     */
    private function resolve_bulk_identity( string $bulk_run_uuid, int $bulk_item_id, int $user_id, bool $allow_cleared_item = false ) {
        $bulk_run_uuid = strtolower( trim( $bulk_run_uuid ) );
        if ( $user_id <= 0 || get_current_user_id() !== $user_id || ! wp_is_uuid( $bulk_run_uuid ) || $bulk_item_id <= 0 ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_invalid', 400 );
        }

        $repo = Bulk_Repo::instance();
        $run  = $repo->get_run_by_uuid( $bulk_run_uuid, $user_id );
        $item = $repo->get_item_by_id( $bulk_item_id, $user_id );
        if ( ! is_array( $run ) || ! is_array( $item ) || (int) ( $item['run_id'] ?? 0 ) !== (int) ( $run['id'] ?? 0 ) ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
        }

        $workflow   = sanitize_key( (string) ( $run['workflow'] ?? '' ) );
        $target     = sanitize_key( (string) ( $item['subject_type'] ?? '' ) );
        $subject_id = absint( $item['subject_id'] ?? 0 );
        $request_id = strtolower( trim( (string) ( $item['request_id'] ?? '' ) ) );
        $run_id     = trim( (string) ( $item['run_id_single'] ?? '' ) );
        $status     = sanitize_key( (string) ( $item['status'] ?? '' ) );
        if (
            ! in_array( $workflow, Workflow_Request_Store::WORKFLOWS, true )
            || ! in_array( $target, [ 'product', 'media', 'post', 'page' ], true )
            || $subject_id <= 0
            || ! wp_is_uuid( $request_id )
            || '' === $run_id
            || ! in_array( $status, [ 'completed', 'partial' ], true )
        ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
        }

        $row = Logger::instance()->get_run_row( $run_id );
        $item_job_ids = $this->normalize_job_ids( $item['job_ids'] ?? [] );
        $row_job_ids  = $this->normalize_job_ids( $row['job_ids'] ?? [] );
        $item_run_uuid = strtolower( trim( (string) ( $item['run_item_uuid'] ?? '' ) ) );
        $row_run_uuid = strtolower( trim( (string) ( $row['run_item_uuid'] ?? '' ) ) );
        $item_group_uuid = strtolower( trim( (string) ( $item['campaign_group_uuid'] ?? '' ) ) );
        $row_group_uuid = strtolower( trim( (string) ( $row['campaign_group_uuid'] ?? '' ) ) );
        if (
            ! $this->row_is_owned( $row, $user_id )
            || ! hash_equals( $request_id, strtolower( (string) ( $row['request_id'] ?? '' ) ) )
            || $workflow !== sanitize_key( (string) ( $row['workflow'] ?? '' ) )
            || $target !== sanitize_key( (string) ( $row['run_target'] ?? '' ) )
            || $subject_id !== (int) ( $row['subject_id'] ?? 0 )
            || ! hash_equals( $bulk_run_uuid, strtolower( (string) ( $row['bulk_run_uuid'] ?? '' ) ) )
            || $bulk_item_id !== absint( $row['bulk_item_id'] ?? 0 )
            || ( wp_is_uuid( $row_run_uuid ) && ( ! wp_is_uuid( $item_run_uuid ) || ! hash_equals( $item_run_uuid, $row_run_uuid ) ) )
            || ( wp_is_uuid( $row_run_uuid ) && '' !== $item_group_uuid && ! wp_is_uuid( $item_group_uuid ) )
            || ( wp_is_uuid( $row_run_uuid ) && ! hash_equals( $item_group_uuid, $row_group_uuid ) )
            || ( wp_is_uuid( $row_run_uuid ) && absint( $item['item_ordinal'] ?? 0 ) !== absint( $row['item_ordinal'] ?? 0 ) )
            || empty( $item_job_ids )
            || $item_job_ids !== $row_job_ids
        ) {
            return $this->mutation_error( 'fotogenic_ai_result_identity_forbidden', 403 );
        }

        $item_attachment_ids = $this->decode_id_list( $item['attachment_ids'] ?? [] );
        $primary_id          = absint( $item['attachment_id'] ?? 0 );
        if ( $primary_id > 0 && ! in_array( $primary_id, $item_attachment_ids, true ) ) {
            array_unshift( $item_attachment_ids, $primary_id );
        }
        $row_attachment_ids = $this->ordered_attachment_ids(
            $row_job_ids,
			Generate_Result_Authority::admitted_attachment_map( $row )
        );
        $item_compare = $item_attachment_ids;
        $row_compare  = $row_attachment_ids;
        sort( $item_compare, SORT_NUMERIC );
        sort( $row_compare, SORT_NUMERIC );
        $attachments_exact = $item_compare === $row_compare;
        $attachments_match = $attachments_exact;
        if ( ! $attachments_match && $allow_cleared_item ) {
            $missing_ids = array_diff( $row_compare, $item_compare );
            $attachments_match = empty( array_diff( $item_compare, $row_compare ) );
            foreach ( $missing_ids as $missing_id ) {
                if ( get_post( $missing_id ) ) {
                    $attachments_match = false;
                    break;
                }
            }
        }
        if ( ( empty( $item_compare ) && ! $allow_cleared_item ) || ! $attachments_match ) {
            return $this->mutation_error( 'fotogenic_ai_result_attachment_forbidden', 403 );
        }

        return [
            'run'               => $run,
            'item'              => $item,
            'row'               => $row,
            'attachment_ids'    => $item_attachment_ids,
            'attachments_exact' => $attachments_exact,
            'attachments_removed' => $this->persisted_outputs_removed( $row_attachment_ids ),
        ];
    }

    /**
     * A non-empty Logger attachment set is authoritative for saved output identity.
     * It is removed only when every persisted WordPress post is definitively absent.
     */
    private function persisted_outputs_removed( array $attachment_ids ): bool {
        if ( empty( $attachment_ids ) ) {
            return false;
        }
        foreach ( $attachment_ids as $attachment_id ) {
            if ( get_post( $attachment_id ) ) {
                return false;
            }
        }
        return true;
    }

    /** @return array<string, mixed> */
    private function present_source( array $row, int $user_id ): array {
        $source_id = absint( $row['source_attachment_id'] ?? 0 );
        if ( $source_id <= 0 ) {
            $source_ids = $this->decode_id_list( $row['source_attachment_ids'] ?? [] );
            $source_id  = (int) ( $source_ids[0] ?? 0 );
        }
        if ( $source_id <= 0 ) {
            return [];
        }
        if ( ! get_post( $source_id ) ) {
            return [
                'attachmentId' => $source_id,
                'missing'      => true,
                'label'        => __( 'Source image no longer available', 'fotogenic-ai' ),
            ];
        }
        return $this->present_attachment_facts( $source_id, $user_id );
    }

    /** @return array<int, array<string, mixed>> */
    private function present_sources( array $row, int $user_id ): array {
        $source_ids = $this->decode_id_list( $row['source_attachment_ids'] ?? [] );
        $primary_id = absint( $row['source_attachment_id'] ?? 0 );
        if ( $primary_id > 0 && ! in_array( $primary_id, $source_ids, true ) ) {
            array_unshift( $source_ids, $primary_id );
        }
        if ( empty( $source_ids ) && $primary_id > 0 ) {
            $source_ids[] = $primary_id;
        }

        $sources = [];
        foreach ( array_slice( $source_ids, 0, self::MAX_RESULT_ATTACHMENTS ) as $source_id ) {
            if ( ! get_post( $source_id ) ) {
                continue;
            }
            $presented = $this->present_attachment_facts( $source_id, $user_id );
            if ( ! empty( $presented ) ) {
                $sources[] = $presented;
            }
        }
        return $sources;
    }

    /** @return array<string, mixed> */
    private function present_current_product( array $row, int $user_id ): array {
        if ( 'product' !== sanitize_key( (string) ( $row['run_target'] ?? '' ) ) ) {
            return [];
        }

        $target = $this->target_snapshot_from_row( $row );
        if ( is_wp_error( $target ) ) {
            return [];
        }

        $target_id   = absint( $target['targetId'] ?? 0 );
		$target_type = sanitize_key( (string) ( $target['targetType'] ?? '' ) );
        if ( $target_id <= 0 || ! current_user_can( 'edit_post', $target_id ) ) {
            return [];
        }

        $image_id = (int) get_post_thumbnail_id( $target_id );
        if ( $image_id <= 0 || ! get_post( $image_id ) ) {
            return [
                'missing' => true,
                'label'   => 'variation' === $target_type
                    ? __( 'This variation has no current image', 'fotogenic-ai' )
                    : __( 'This product has no current image', 'fotogenic-ai' ),
            ];
        }

        return $this->present_attachment_facts( $image_id, $user_id );
    }

    /** @return array<string, string> */
    private function present_target_links( array $row, int $user_id ): array {
        if ( $user_id <= 0 || get_current_user_id() !== $user_id ) {
            return [];
        }
        $target = $this->target_snapshot_from_row( $row );
        if ( is_wp_error( $target ) ) {
            return [];
        }
        $target_id = absint( $target['targetId'] ?? 0 );
        if ( $target_id <= 0 || ! current_user_can( 'edit_post', $target_id ) ) {
            return [];
        }
        $edit_url = get_edit_post_link( $target_id, 'raw' );
        $view_url = get_permalink( $target_id );
        return array_filter(
            [
                'editUrl' => is_string( $edit_url ) ? esc_url_raw( $edit_url ) : '',
                'viewUrl' => is_string( $view_url ) ? esc_url_raw( $view_url ) : '',
            ]
        );
    }

    private function delivery_label( string $workflow, string $target ): string {
        if ( 'product' === $target && in_array( $workflow, [ 'product_images', 'campaign_set' ], true ) ) {
            return __( 'Review before product changes', 'fotogenic-ai' );
        }
        if ( in_array( $target, [ 'post', 'page' ], true ) ) {
            return __( 'Review before featured-image changes', 'fotogenic-ai' );
        }
        return __( 'Media Library only', 'fotogenic-ai' );
    }

    /** @return array<string, mixed> */
    private function present_attachment( int $attachment_id, array $row, int $user_id, bool $include_delete ): array {
        if ( $attachment_id <= 0 || ! get_post( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) {
            return [];
        }
        $facts = $this->present_attachment_facts( $attachment_id, $user_id );
        if ( empty( $facts ) ) {
            return [];
        }
        $facts['provenanceValid'] = true;
        if ( $include_delete ) {
			$facts['canDelete'] = wp_is_uuid( strtolower( trim( (string) ( $row['request_id'] ?? '' ) ) ) )
                && current_user_can( 'delete_post', $attachment_id );
        }
        return $facts;
    }

    /** @return array<string, mixed> */
    private function present_attachment_facts( int $attachment_id, int $user_id ): array {
		$attachment = get_post( $attachment_id );
        $full_url = class_exists( Media_Saver::class )
            ? (string) Media_Saver::get_attachment_view_url( $attachment_id, 'full' )
            : (string) wp_get_attachment_url( $attachment_id );
        if ( '' === $full_url ) {
            return [];
        }
        $thumb_url = class_exists( Media_Saver::class )
            ? (string) Media_Saver::get_attachment_view_url( $attachment_id, 'medium_large' )
            : '';
        $metadata  = wp_get_attachment_metadata( $attachment_id );
        $edit_url  = current_user_can( 'edit_post', $attachment_id ) ? get_edit_post_link( $attachment_id, 'raw' ) : '';
        $path      = get_attached_file( $attachment_id );
        $bytes     = is_string( $path ) && '' !== $path && is_readable( $path )
            ? ( function_exists( 'wp_filesize' ) ? (int) wp_filesize( $path ) : (int) filesize( $path ) )
            : 0;
        $created_at  = (string) get_post_time( DATE_ATOM, true, $attachment_id );
        $modified_at = (string) get_post_modified_time( DATE_ATOM, true, $attachment_id );
        return [
            'attachmentId' => $attachment_id,
            'fullUrl'      => esc_url_raw( $full_url ),
            'thumbUrl'     => esc_url_raw( $thumb_url ?: $full_url ),
            'editUrl'      => is_string( $edit_url ) ? esc_url_raw( $edit_url ) : '',
			'label'        => fotogenic_ai_plain_text_label( $attachment instanceof \WP_Post ? (string) $attachment->post_title : '' ) ?: sprintf(
                /* translators: %d: Media Library attachment ID. */
                __( 'Image %d', 'fotogenic-ai' ),
                $attachment_id
            ),
            'alt'          => sanitize_text_field( (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ),
            'width'        => is_array( $metadata ) ? absint( $metadata['width'] ?? 0 ) : 0,
            'height'       => is_array( $metadata ) ? absint( $metadata['height'] ?? 0 ) : 0,
            'mime'         => sanitize_mime_type( (string) get_post_mime_type( $attachment_id ) ),
            'bytes'        => max( 0, $bytes ),
            'createdAt'    => $created_at,
			'modifiedAt'   => $modified_at,
        ];
    }

    private function row_is_owned( array $row, int $user_id ): bool {
        return $user_id > 0
            && ! empty( $row['run_id'] )
            && (int) ( $row['user_id'] ?? 0 ) === $user_id
            && in_array( sanitize_key( (string) ( $row['workflow'] ?? '' ) ), Workflow_Request_Store::WORKFLOWS, true )
            && in_array( sanitize_key( (string) ( $row['run_target'] ?? '' ) ), [ 'product', 'media', 'post', 'page' ], true )
            && (int) ( $row['subject_id'] ?? 0 ) > 0;
    }

    /** @return int[] */
	private function ordered_attachment_ids( array $job_ids, array $attachments_map ): array {
		$ordered = [];
		foreach ( $job_ids as $job_id ) {
			foreach ( Generate_Output_Map::for_job( $attachments_map, $job_id ) as $attachment_id ) {
				if ( $attachment_id > 0 && ! in_array( $attachment_id, $ordered, true ) ) {
					$ordered[] = $attachment_id;
				}
			}
		}
		return $ordered;
	}

    /** @return int[] */
    private function decode_id_list( $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 ) ) ) )
            : [];
    }

    private function mutation_error( string $code, int $status ): \WP_Error {
        return new \WP_Error(
            $code,
            __( 'The requested result action is not authorized for this saved run.', 'fotogenic-ai' ),
            [ 'status' => $status ]
        );
    }

	private function stored_authority_row( array $record, array $stored, array $job_ids, string $workflow ): array {
		$run_id = trim( (string) ( $record['run_id'] ?? $stored['runId'] ?? '' ) );
		$row    = '' !== $run_id ? Logger::instance()->get_run_row( $run_id ) : [];
		if ( ! empty( $row ) ) {
			return $row;
		}

		$finalize_meta = [];
		$admission     = Generate_Campaign_Admission::normalize( $record['admission'] ?? null );
		if ( 'campaign_set' === $workflow && ! empty( $admission ) ) {
			$finalize_meta[ Generate_Campaign_Admission::FINALIZE_META_KEY ] = $admission;
		}

		return [
			'user_id'         => absint( $record['user_id'] ?? 0 ),
			'workflow'        => $workflow,
			'run_id'          => $run_id,
			'request_id'      => strtolower( trim( (string) ( $record['request_id'] ?? $stored['requestId'] ?? '' ) ) ),
			'run_target'      => sanitize_key( (string) ( $stored['sourceType'] ?? '' ) ),
			'subject_id'      => absint( $stored['subjectId'] ?? 0 ),
			'run_item_uuid'   => strtolower( trim( (string) ( $stored['runItemId'] ?? '' ) ) ),
			'job_ids'         => $job_ids,
			'attachments_map' => $stored['attachmentsMap'] ?? [],
			'finalize_meta'   => $finalize_meta,
		];
	}

	/** @return array<string, mixed> */
	private function decode_object( $value ): array {
		if ( is_string( $value ) ) {
			$trimmed = trim( $value );
			$decoded = json_decode( $trimmed, true );
			return is_array( $decoded ) && fotogenic_ai_str_starts_with( $trimmed, '{' ) ? $decoded : [];
		}
		return is_array( $value ) && ( empty( $value ) || ! fotogenic_ai_array_is_list( $value ) ) ? $value : [];
	}
}
