<?php

namespace Fotogenic_AI;

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

/**
 * Finalize every output owned by one accepted upstream job.
 *
 * Product Images and Increase Resolution own one output per job. Campaign Set
 * owns every ordered terminal output. Existing attachments are
 * reused only when their exact job, output and run identity match.
 */
final class Generate_Job_Finalizer {
    private const MAX_ASSET_ATTEMPTS = 6;
    private const CAMPAIGN_MAX_ASSET_ATTEMPTS = 30;
	private const MAX_TERMINAL_TYPE_LENGTH = 64;
    private const WORKFLOWS = [ 'product_images', 'campaign_set', 'increase_resolution' ];

    /**
     * Fetch GET /v2/job/{id}?dataType=cdn and save all completed outputs locally.
     *
     * @param string $job_id  Job ID.
	 * @param array  $context Canonical run identity and attachment metadata context.
     * @return array{status:string,attachments_map:array<string,int>,attachment_ids:int[],error_code?:string,retryable?:bool}
     */
    public function finalize_job( string $job_id, array $context ): array {
        $job_id = trim( $job_id );
        if ( '' === $job_id ) {
            return $this->result( 'failed', [], 'invalid_job', false );
        }
        $context = $this->hydrate_workflow_context( $context );
		$workflow = sanitize_key( (string) ( $context['workflow'] ?? '' ) );
		$existing = $this->find_attachments_for_job( $job_id, $context );
		if ( 'campaign_set' !== $workflow && ! empty( $existing ) ) {
			$attachment_id = (int) reset( $existing );
			$attachment_context = array_merge( $context, [ 'job_id' => $job_id ] );
			if ( ! $this->ensure_attachment_provenance( $attachment_id, $attachment_context ) ) {
				return $this->result( 'not_ready', [], 'attachment_provenance_failed', true );
			}
			return $this->result( 'completed', $existing );
		}

        $job_payload = Api_Client::instance()->request(
            'GET',
            'job',
            [],
            [
                'path_params'  => [ 'jobId' => $job_id ],
                'query_params' => [ 'dataType' => 'cdn' ],
                'run_id'       => (string) ( $context['run_id'] ?? '' ),
            ]
        );
        if ( is_wp_error( $job_payload ) ) {
            $error_code = sanitize_key( (string) ( $job_payload->get_error_code() ?: 'job_fetch_failed' ) );
            $retryable = $this->is_retryable_error( $job_payload );
            return $this->result( $retryable ? 'not_ready' : 'failed', [], $error_code, $retryable );
        }

		$job_status = $this->validate_terminal_identity( $job_payload, $job_id, $workflow );
		if ( is_wp_error( $job_status ) ) {
			return $this->result( 'failed', [], sanitize_key( (string) $job_status->get_error_code() ), false );
		}
		if ( 'campaign_set' !== $workflow && 'failed' === $job_status ) {
			return $this->result( 'failed', [], $this->terminal_error_code( $job_payload ), false );
		}
		if ( ! in_array( $job_status, [ 'completed', 'failed' ], true ) ) {
            return $this->result( 'not_ready', [], 'job_not_ready', true );
        }

		$terminal_output_counts = [];
		$terminal_output_errors = [];
		if ( 'campaign_set' === $workflow ) {
			$campaign_terminal = $this->parse_campaign_terminal(
				$job_payload,
				$job_id,
				$context['campaign_admission'] ?? []
			);
			if ( is_wp_error( $campaign_terminal ) ) {
				$error_code = sanitize_key( (string) $campaign_terminal->get_error_code() );
				return $this->result( 'failed', [], $error_code, false );
			}
			$terminal_output_counts = $campaign_terminal['counts'];
			$terminal_output_errors = $campaign_terminal['errors'];
			$outputs = $campaign_terminal['outputs'];
			if ( 'failed' === $job_status ) {
				$error_code = $this->terminal_error_code( $job_payload );
				if ( 'failed' === $error_code ) {
					$error_code = Generate_Campaign_Admission::primary_output_error_code(
						$terminal_output_errors,
						$context['campaign_admission'] ?? []
					) ?: 'failed';
				}
				return $this->result( 'failed', [], $error_code, false, $terminal_output_counts, $terminal_output_errors );
			}
		} else {
			$outputs = $this->extract_single_output( $job_payload, $job_id, $workflow );
		}
		if ( is_wp_error( $outputs ) ) {
			$error_code = sanitize_key( (string) $outputs->get_error_code() );
			$error_data = $outputs->get_error_data();
			$retryable  = is_array( $error_data ) && ! empty( $error_data['retryable'] );
            return $this->result( $retryable ? 'not_ready' : 'failed', [], $error_code, $retryable );
        }

		$attachments_map = [];
		foreach ( $outputs as $output ) {
			$output_key = (string) $output['output_key'];
			$output_context = array_merge(
				$context,
				[
					'job_id'         => $job_id,
					'asset_id'       => (string) $output['asset_id'],
					'asset_order'    => (int) $output['order_index'],
				]
			);
			$existing_attachment_id = absint( $existing[ $output_key ] ?? 0 );
			if ( $existing_attachment_id > 0 ) {
				if ( ! $this->ensure_attachment_provenance( $existing_attachment_id, $output_context ) ) {
					return $this->result( 'not_ready', $attachments_map, 'attachment_provenance_failed', true, $terminal_output_counts, $terminal_output_errors );
				}
				$attachments_map[ $output_key ] = $existing_attachment_id;
				continue;
			}

			$saved = Media_Saver::instance()->save_generated_image_from_url( (string) $output['image_url'], $output_context );
			if ( is_wp_error( $saved ) ) {
				$error_code = sanitize_key( (string) ( $saved->get_error_code() ?: 'save_failed' ) );
				$retryable  = $this->is_retryable_error( $saved );
				return $this->result( $retryable ? 'not_ready' : 'failed', $attachments_map, $error_code, $retryable, $terminal_output_counts, $terminal_output_errors );
			}

			$attachment_id = absint( $saved['attachment_id'] ?? 0 );
			if ( $attachment_id <= 0 ) {
				return $this->result( 'failed', $attachments_map, 'save_missing_attachment', false, $terminal_output_counts, $terminal_output_errors );
			}
			if ( ! $this->store_workflow_attachment_meta( $attachment_id, $output_context ) || ! Generate_Result_Authority::attachment_matches_context( $attachment_id, $output_context ) ) {
				wp_delete_attachment( $attachment_id, true );
				return $this->result( 'not_ready', $attachments_map, 'attachment_provenance_failed', true, $terminal_output_counts, $terminal_output_errors );
			}
			$retained_new_attachment = true;
			$existing_after = $this->find_attachments_for_job( $job_id, $context );
			$existing_after_id = absint( $existing_after[ $output_key ] ?? 0 );
			if ( $existing_after_id > 0 && $existing_after_id !== $attachment_id ) {
				wp_delete_attachment( $attachment_id, true );
				$attachment_id = $existing_after_id;
				$retained_new_attachment = false;
			}
			$attachments_map[ $output_key ] = $attachment_id;

			if ( $retained_new_attachment && function_exists( 'do_action' ) ) {
            /**
             * Fires once after a newly saved generated attachment and its
             * provenance metadata survive the final idempotency check.
             *
             * @param array $event Safe immutable attachment metadata.
             */
            do_action(
                'fotogenic_ai_generated_attachment_saved',
                [
                    'attachmentId' => $attachment_id,
                    'jobId'       => $job_id,
                    'workflow'    => sanitize_key( (string) ( $context['workflow'] ?? '' ) ),
                    'runId'       => substr( sanitize_text_field( (string) ( $context['run_id'] ?? '' ) ), 0, 64 ),
                    'requestId'   => substr( sanitize_text_field( (string) ( $context['request_id'] ?? '' ) ), 0, 64 ),
                    'sourceType'  => sanitize_key( (string) ( $context['run_target'] ?? '' ) ),
                    'subjectId'   => absint( $context['subject_id'] ?? 0 ),
                ]
            );
			}
        }

		$error_code = Generate_Campaign_Admission::primary_output_error_code(
			$terminal_output_errors,
			$context['campaign_admission'] ?? []
		);
		return $this->result( 'completed', $attachments_map, $error_code, false, $terminal_output_counts, $terminal_output_errors );
    }

	/** @return array{status:string,attachments_map:array<string,int>,attachment_ids:int[],error_code?:string,retryable?:bool,terminal_output_counts?:array{requested:int,completed:int,failed:int},terminal_output_errors?:array<int,array{output_id:string,output_index:int,job_id:string,error_code:string}>} */
	private function result( string $status, array $attachments_map, string $error_code = '', bool $retryable = false, array $terminal_output_counts = [], array $terminal_output_errors = [] ): array {
		$result = [
			'status'          => $status,
			'attachments_map' => $attachments_map,
			'attachment_ids'  => Generate_Output_Map::attachment_ids( $attachments_map ),
		];
		if ( '' !== $error_code ) {
			$result['error_code'] = $error_code;
			$result['retryable']  = $retryable;
		}
		if ( ! empty( $terminal_output_counts ) ) {
			$result['terminal_output_counts'] = $terminal_output_counts;
			$result['terminal_output_errors'] = $terminal_output_errors;
		}
		return $result;
	}

	/** @return string|\WP_Error */
	private function validate_terminal_identity( array $job_payload, string $job_id, string $workflow ) {
		$payload_job_id = $job_payload['jobId'] ?? null;
		$status         = $job_payload['status'] ?? null;
		$type           = $job_payload['type'] ?? null;
		if (
			! is_string( $payload_job_id )
			|| '' === $payload_job_id
			|| ! hash_equals( $job_id, $payload_job_id )
			|| ! is_string( $status )
			|| ! in_array( $status, [ 'processing', 'completed', 'failed' ], true )
			|| ! is_string( $type )
			|| '' === $type
			|| trim( $type ) !== $type
			|| strlen( $type ) > self::MAX_TERMINAL_TYPE_LENGTH
		) {
			return new \WP_Error( 'invalid_job_terminal', '', [ 'retryable' => false ] );
		}
		if ( 'campaign_set' === $workflow && 'campaign_set' !== $type ) {
			return new \WP_Error( 'invalid_campaign_terminal', '', [ 'retryable' => false ] );
		}
		if ( 'increase_resolution' === $workflow && 'enhance' !== $type ) {
			return new \WP_Error( 'invalid_increase_terminal', '', [ 'retryable' => false ] );
		}
		if ( 'product_images' !== $workflow && 'campaign_set' !== $workflow && 'increase_resolution' !== $workflow ) {
			return new \WP_Error( 'invalid_job_terminal', '', [ 'retryable' => false ] );
		}

		return $status;
	}

	private function terminal_error_code( array $job_payload ): string {
		if ( ! is_string( $job_payload['errorCode'] ?? null ) ) {
			return 'failed';
		}
		$error_code = substr( sanitize_key( $job_payload['errorCode'] ), 0, 64 );
		return '' !== $error_code ? $error_code : 'failed';
	}

	/** @return array<int, array{output_key:string,asset_id:string,order_index:int,image_url:string}>|\WP_Error */
	private function extract_single_output( array $job_payload, string $job_id, string $workflow = '' ) {
		$payload_job_id = $job_payload['jobId'] ?? null;
		$image_url      = $job_payload['imageUrl'] ?? null;
		if (
			! is_string( $payload_job_id )
			|| ! hash_equals( $job_id, $payload_job_id )
			|| ! is_string( $image_url )
			|| '' === trim( $image_url )
		) {
			return new \WP_Error( 'missing_image_url', '', [ 'retryable' => true ] );
		}
		$terminal_type = $job_payload['type'] ?? null;
		if (
			'product_images' === $workflow
			&& (
				! is_string( $terminal_type )
				|| '' === $terminal_type
				|| trim( $terminal_type ) !== $terminal_type
				|| strlen( $terminal_type ) > self::MAX_TERMINAL_TYPE_LENGTH
			)
		) {
			return new \WP_Error( 'invalid_generate_terminal', '', [ 'retryable' => false ] );
		}
		if ( 'increase_resolution' === $workflow ) {
			$configuration = $job_payload['configuration'] ?? null;
			if (
				'enhance' !== ( $job_payload['type'] ?? null )
				|| ! is_array( $configuration )
				|| 'increaseResolution' !== ( $configuration['enhanceType'] ?? null )
				|| '2K' !== ( $configuration['quality'] ?? null )
			) {
				return new \WP_Error( 'invalid_increase_terminal', '', [ 'retryable' => false ] );
			}
		}
		return [ [
			'output_key'  => $job_id,
			'asset_id'    => '',
			'order_index' => 0,
			'image_url'   => $image_url,
		] ];
	}

	/** @return array{counts:array{requested:int,completed:int,failed:int},outputs:array<int,array{output_key:string,asset_id:string,order_index:int,image_url:string}>,errors:array<int,array{output_id:string,output_index:int,job_id:string,error_code:string}>}|\WP_Error */
	private function parse_campaign_terminal( array $job_payload, string $job_id, array $admission ) {
		$identity = $this->validate_terminal_identity( $job_payload, $job_id, 'campaign_set' );
		$admission = Generate_Campaign_Admission::normalize( $admission );
		if (
			is_wp_error( $identity )
			|| ! in_array( $identity, [ 'completed', 'failed' ], true )
			|| empty( $admission )
		) {
			return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
		}
		if ( 'failed' === $identity && $this->is_sparse_whole_job_campaign_failure( $job_payload ) ) {
			if ( ! $this->campaign_admission_matches_job( $admission, $job_id ) ) {
				return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
			}
			$accepted = (int) $admission['accepted_output_count'];
			return [
				'counts'  => [ 'requested' => $accepted, 'completed' => 0, 'failed' => $accepted ],
				'outputs' => [],
				'errors'  => [],
			];
		}
		if ( ! $this->has_explicit_campaign_terminal_fields( $job_payload ) ) {
			return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
		}
		$requested = $job_payload['requestedOutputCount'] ?? null;
		$accepted  = $job_payload['acceptedOutputCount'] ?? null;
		$rejected  = $job_payload['rejectedOutputCount'] ?? null;
		$completed = $job_payload['completedOutputCount'] ?? null;
		$failed    = $job_payload['failedOutputCount'] ?? null;
		$records   = $job_payload['outputs'] ?? null;
		if (
			! is_int( $requested )
			|| ! is_int( $accepted )
			|| ! is_int( $rejected )
			|| ! is_int( $completed )
			|| ! is_int( $failed )
			|| ! is_array( $records )
			|| ! fotogenic_ai_array_is_list( $records )
				|| $requested < 2
				|| $requested > 6
				|| $accepted !== $requested
				|| 0 !== $rejected
				|| $completed < 0
				|| $failed < 0
				|| $completed + $failed !== $accepted
				|| $accepted !== $admission['accepted_output_count']
				|| count( $records ) !== $accepted
			) {
			return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
		}

		$ordered = [];
		$output_errors = [];
		$seen_ids = [];
		$seen_indexes = [];
		$completed_records = 0;
		$failed_records = 0;
		foreach ( $records as $record ) {
			if ( ! is_array( $record ) || ! is_int( $record['outputIndex'] ?? null ) ) {
				return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
			}
			$output_id = $record['outputId'] ?? null;
			$output_job_id = $record['jobId'] ?? null;
			$output_index = (int) $record['outputIndex'];
			$output_status = $record['status'] ?? null;
			$image_url = $record['imageUrl'] ?? null;
			$admitted_output = $admission['outputs'][ $output_index ] ?? null;
			if (
					'' === Upstream_Identifier::normalize( $output_id )
				|| ! is_string( $output_job_id )
				|| ! hash_equals( $job_id, $output_job_id )
				|| $output_index < 0
				|| $output_index >= $accepted
				|| isset( $seen_ids[ $output_id ] )
				|| isset( $seen_indexes[ $output_index ] )
				|| ! in_array( $output_status, [ 'completed', 'failed' ], true )
				|| ! is_array( $admitted_output )
				|| ! hash_equals( (string) $admitted_output['output_id'], $output_id )
				|| (int) $admitted_output['output_index'] !== $output_index
				|| ! hash_equals( (string) $admitted_output['job_id'], $output_job_id )
				|| ( 'completed' === $output_status && ( ! is_string( $image_url ) || '' === trim( $image_url ) ) )
				|| ( 'failed' === $output_status && null !== $image_url && ( ! is_string( $image_url ) || '' !== trim( $image_url ) ) )
				|| (
					'failed' === $output_status
					&& array_key_exists( 'errorCode', $record )
					&& (
						! is_string( $record['errorCode'] )
						|| '' === sanitize_key( $record['errorCode'] )
					)
				)
			) {
				return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
			}
			$seen_ids[ $output_id ] = true;
			$seen_indexes[ $output_index ] = true;
			if ( 'failed' === $output_status ) {
				++$failed_records;
				if ( is_string( $record['errorCode'] ?? null ) ) {
					$error_code = substr( sanitize_key( $record['errorCode'] ), 0, 64 );
					if ( '' !== $error_code ) {
						$output_errors[] = [
							'output_id'    => $output_id,
							'output_index' => $output_index,
							'job_id'       => $output_job_id,
							'error_code'   => $error_code,
						];
					}
				}
				continue;
			}
			++$completed_records;
			$ordered[ $output_index ] = [
				'asset_id'    => $output_id,
				'order_index' => $output_index,
				'image_url'   => $image_url,
			];
		}
		if ( $completed_records !== $completed || $failed_records !== $failed || count( $seen_ids ) !== $accepted ) {
			return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
		}
		for ( $index = 0; $index < $accepted; ++$index ) {
			if ( ! isset( $seen_indexes[ $index ] ) ) {
				return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
			}
		}
		if ( ( 'completed' === $identity && 0 === $completed ) || ( 'failed' === $identity && ( 0 !== $completed || $failed !== $accepted ) ) ) {
			return new \WP_Error( 'campaign_assets_unavailable', '', [ 'retryable' => false ] );
		}
		ksort( $ordered, SORT_NUMERIC );

		$outputs = [];
		foreach ( $ordered as $output ) {
			$order_index = (int) $output['order_index'];
			$output_key = Generate_Output_Map::campaign_key( $job_id, (string) $output['asset_id'], $order_index );
			if ( '' === $output_key ) {
				return new \WP_Error( 'invalid_campaign_assets', '', [ 'retryable' => false ] );
			}
			$outputs[] = [
				'output_key'  => $output_key,
				'asset_id'    => (string) $output['asset_id'],
				'order_index' => $order_index,
				'image_url'   => (string) $output['image_url'],
			];
		}
		$output_errors = Generate_Campaign_Admission::normalize_output_errors( $output_errors, $admission );
		return [
			'counts' => [ 'requested' => $requested, 'completed' => $completed, 'failed' => $failed ],
			'outputs' => $outputs,
			'errors' => $output_errors,
		];
	}

	/**
	 * A failed Campaign job may expose only the stable terminal identity surface.
	 * Any output serializer field selects the stricter per-output parser instead
	 * of allowing an incomplete hybrid to masquerade as a whole-job failure.
	 */
	private function is_sparse_whole_job_campaign_failure( array $job_payload ): bool {
		foreach (
			[
				'requestedOutputCount',
				'acceptedOutputCount',
				'rejectedOutputCount',
				'completedOutputCount',
				'failedOutputCount',
				'outputs',
			] as $field
		) {
			if ( array_key_exists( $field, $job_payload ) ) {
				return false;
			}
		}

		return true;
	}

	private function campaign_admission_matches_job( array $admission, string $job_id ): bool {
		foreach ( $admission['outputs'] as $output ) {
			if ( ! hash_equals( $job_id, (string) $output['job_id'] ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Any explicit terminal-output field selects the complete strict serializer.
	 * Nested campaignSet data remains extensible metadata and is never promoted
	 * into output authority.
	 */
	private function has_explicit_campaign_terminal_fields( array $job_payload ): bool {
		$top_level_fields = [
			'requestedOutputCount',
			'acceptedOutputCount',
			'rejectedOutputCount',
			'completedOutputCount',
			'failedOutputCount',
			'outputs',
		];
		foreach ( $top_level_fields as $field ) {
			if ( array_key_exists( $field, $job_payload ) ) {
				return true;
			}
		}

		return false;
	}

    private function hydrate_workflow_context( array $context ): array {
        $run_id = trim( (string) ( $context['run_id'] ?? '' ) );
        if ( '' === $run_id ) {
            return $context;
        }
        $row = Logger::instance()->get_run_row( $run_id );
		if ( empty( $row ) ) {
			$context['campaign_admission'] = Generate_Campaign_Admission::normalize( $context['campaign_admission'] ?? null );
			return $context;
        }
        foreach ( [ 'workflow', 'request_id', 'source_attachment_id', 'expected_output_count', 'estimated_cost', 'run_item_uuid', 'campaign_group_uuid', 'item_ordinal', 'target_type', 'parent_product_id', 'variation_id' ] as $key ) {
            if ( ! array_key_exists( $key, $context ) || '' === (string) $context[ $key ] ) {
                $context[ $key ] = $row[ $key ] ?? '';
            }
        }
        $target_snapshot = $context['target_snapshot'] ?? $row['target_snapshot'] ?? null;
        if ( is_string( $target_snapshot ) ) {
            $decoded = json_decode( $target_snapshot, true );
            $target_snapshot = is_array( $decoded ) ? $decoded : null;
        }
        $target_snapshot = Workflow_Item_Identity::normalize_target_snapshot( $target_snapshot );
        if ( ! is_wp_error( $target_snapshot ) ) {
            $context['target_snapshot'] = $target_snapshot;
        }
		if ( empty( $context['source_attachment_ids'] ) && ! empty( $row['source_attachment_ids'] ) ) {
            $source_ids = json_decode( (string) $row['source_attachment_ids'], true );
            if ( is_array( $source_ids ) ) {
                $context['source_attachment_ids'] = array_values( array_unique( array_filter( array_map( 'absint', $source_ids ) ) ) );
            }
		}
		$context['campaign_admission'] = Generate_Campaign_Admission::from_finalize_meta( $row['finalize_meta'] ?? null );
        return $context;
    }

    private function store_workflow_attachment_meta( int $attachment_id, array $context ): bool {
        if ( $attachment_id <= 0 ) {
			return false;
        }
        $workflow = sanitize_key( (string) ( $context['workflow'] ?? '' ) );
		$run_id        = substr( sanitize_text_field( trim( (string) ( $context['run_id'] ?? '' ) ) ), 0, 64 );
		$request_id    = strtolower( trim( (string) ( $context['request_id'] ?? '' ) ) );
        $run_target = sanitize_key( (string) ( $context['run_target'] ?? '' ) );
		$subject_id     = absint( $context['subject_id'] ?? 0 );
        $run_item_uuid = strtolower( trim( (string) ( $context['run_item_uuid'] ?? '' ) ) );
		if (
			! in_array( $workflow, self::WORKFLOWS, true )
			|| '' === $run_id
			|| ! wp_is_uuid( $request_id )
			|| ! in_array( $run_target, [ 'product', 'media', 'post', 'page' ], true )
			|| $subject_id <= 0
			|| ! wp_is_uuid( $run_item_uuid )
		) {
			return false;
		}

		$expected = [
			'_fotogenic_ai_workflow'      => $workflow,
			'_fotogenic_ai_run_id'        => $run_id,
			'_fotogenic_ai_request_id'    => $request_id,
			'_fotogenic_ai_run_target'    => $run_target,
			'_fotogenic_ai_subject_id'    => $subject_id,
			'_fotogenic_ai_run_item_uuid' => $run_item_uuid,
		];
        $campaign_group_uuid = strtolower( trim( (string) ( $context['campaign_group_uuid'] ?? '' ) ) );
        if ( wp_is_uuid( $campaign_group_uuid ) ) {
			$expected['_fotogenic_ai_campaign_group_uuid'] = $campaign_group_uuid;
        }
        $item_ordinal = absint( $context['item_ordinal'] ?? 0 );
        if ( $item_ordinal > 0 ) {
			$expected['_fotogenic_ai_item_ordinal'] = $item_ordinal;
        }
        $target_snapshot = Workflow_Item_Identity::normalize_target_snapshot( $context['target_snapshot'] ?? null );
        if ( ! is_wp_error( $target_snapshot ) ) {
			$expected['_fotogenic_ai_target_type']       = (string) $target_snapshot['targetType'];
			$expected['_fotogenic_ai_parent_product_id'] = (int) $target_snapshot['parentProductId'];
			$expected['_fotogenic_ai_variation_id']      = (int) $target_snapshot['variationId'];
			$expected['_fotogenic_ai_target_snapshot']   = $target_snapshot;
        }
        $source_attachment_id = absint( $context['source_attachment_id'] ?? 0 );
        if ( $source_attachment_id > 0 && $source_attachment_id !== $attachment_id ) {
			$expected['_fotogenic_ai_source_attachment_id'] = $source_attachment_id;
        }
        $source_attachment_ids = $context['source_attachment_ids'] ?? [];
        if ( is_array( $source_attachment_ids ) ) {
            $source_attachment_ids = array_values( array_unique( array_filter( array_map( 'absint', $source_attachment_ids ) ) ) );
            if ( ! empty( $source_attachment_ids ) ) {
				$expected['_fotogenic_ai_source_attachment_ids'] = $source_attachment_ids;
            }
        }

		foreach ( $expected as $meta_key => $value ) {
			update_post_meta( $attachment_id, $meta_key, $value );
		}
		foreach ( $expected as $meta_key => $value ) {
			$stored = get_post_meta( $attachment_id, $meta_key, true );
			if ( is_int( $value ) ? (int) $stored !== $value : $stored !== $value ) {
				return false;
			}
		}
		return true;
    }

	private function ensure_attachment_provenance( int $attachment_id, array $context ): bool {
		return Media_Saver::instance()->ensure_generated_attachment_context( $attachment_id, $context )
			&& $this->store_workflow_attachment_meta( $attachment_id, $context )
			&& Generate_Result_Authority::attachment_matches_context( $attachment_id, $context );
	}

    /**
     * Find every attachment for the same job, output and generation identity.
     *
     * A job ID alone is not sufficient ownership proof. Reuse requires the
     * attachment owner, workflow, run, request, target and subject to match the
     * current trusted server-side context.
     *
     * @param string $job_id  Job ID.
     * @param array  $context Trusted run context.
     * @return array<string, int>
     */
    public function find_attachments_for_job( string $job_id, array $context = [] ): array {
		$job_id = Upstream_Identifier::normalize( $job_id );
        $identity = $this->normalize_attachment_identity( $context );
        if ( '' === $job_id || empty( $identity ) ) {
            return [];
        }
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Bounded exact plugin job lookup; ownership and workflow identity are verified below.
        $posts = get_posts( [
            'post_type'      => 'attachment',
            'post_status'    => 'inherit',
            'fields'         => 'ids',
            'posts_per_page' => 20,
            'meta_key'       => '_fotogenic_ai_job_id',
            'meta_value'     => $job_id,
            'orderby'        => 'ID',
            'order'          => 'ASC',
            'no_found_rows'  => true,
        ] );
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
		$attachments = [];
        foreach ( $posts as $attachment_id ) {
            $attachment_id = absint( $attachment_id );
			if ( 'campaign_set' === (string) $identity['workflow'] ) {
				if (
					! metadata_exists( 'post', $attachment_id, '_fotogenic_ai_asset_id' )
					|| ! metadata_exists( 'post', $attachment_id, '_fotogenic_ai_asset_order' )
				) {
					continue;
				}
				$asset_id   = trim( (string) get_post_meta( $attachment_id, '_fotogenic_ai_asset_id', true ) );
				$order_index = (int) get_post_meta( $attachment_id, '_fotogenic_ai_asset_order', true );
				$output_key  = Generate_Output_Map::campaign_key( $job_id, $asset_id, $order_index );
				$attachment_context = array_merge( $identity, [ 'job_id' => $job_id, 'asset_id' => $asset_id, 'asset_order' => $order_index ] );
				if ( '' !== $output_key && ! isset( $attachments[ $output_key ] ) && Generate_Result_Authority::attachment_matches_context( $attachment_id, $attachment_context ) ) {
					$attachments[ $output_key ] = $attachment_id;
				}
			} elseif ( ! isset( $attachments[ $job_id ] ) && Generate_Result_Authority::attachment_matches_context( $attachment_id, array_merge( $identity, [ 'job_id' => $job_id ] ) ) ) {
				$attachments[ $job_id ] = $attachment_id;
            }
        }
		ksort( $attachments, SORT_NATURAL );
        return $attachments;
    }

    /**
     * Max attempts per job before treating as asset timeout.
     *
     * @return int
     */
    public function get_max_asset_attempts( string $workflow = '' ): int {
        return 'campaign_set' === sanitize_key( $workflow )
            ? self::CAMPAIGN_MAX_ASSET_ATTEMPTS
            : self::MAX_ASSET_ATTEMPTS;
    }

    private function normalize_attachment_identity( array $context ): array {
        $user_id = absint( $context['user_id'] ?? 0 );
        $workflow = sanitize_key( (string) ( $context['workflow'] ?? '' ) );
        $run_id = trim( (string) ( $context['run_id'] ?? '' ) );
        $request_id = strtolower( trim( (string) ( $context['request_id'] ?? '' ) ) );
        $run_target = sanitize_key( (string) ( $context['run_target'] ?? '' ) );
        $subject_id = absint( $context['subject_id'] ?? 0 );
        if (
            $user_id <= 0
            || ! in_array( $workflow, self::WORKFLOWS, true )
            || '' === $run_id
			|| ! wp_is_uuid( $request_id )
			|| ! in_array( $run_target, [ 'product', 'media', 'post', 'page' ], true )
			|| $subject_id <= 0
			|| ! wp_is_uuid( strtolower( trim( (string) ( $context['run_item_uuid'] ?? '' ) ) ) )
        ) {
            return [];
        }
		$identity = [
            'user_id'    => $user_id,
            'workflow'   => $workflow,
            'run_id'     => substr( sanitize_text_field( $run_id ), 0, 64 ),
            'request_id' => substr( sanitize_text_field( $request_id ), 0, 64 ),
            'run_target' => $run_target,
            'subject_id' => $subject_id,
			'run_item_uuid' => strtolower( trim( (string) $context['run_item_uuid'] ) ),
        ];
		if ( 'campaign_set' === $workflow ) {
			$identity['campaign_admission'] = Generate_Campaign_Admission::normalize( $context['campaign_admission'] ?? null );
			if ( empty( $identity['campaign_admission'] ) ) {
				return [];
			}
		}
		return $identity;
	}

    private function is_retryable_error( \WP_Error $error ): bool {
        $code = sanitize_key( (string) $error->get_error_code() );
        if (
            in_array(
                $code,
                [
                    'fotogenic_ai_attachment_failed',
                    'fotogenic_ai_invalid_image',
                    'fotogenic_ai_invalid_image_url',
                    'save_missing_attachment',
                ],
                true
            )
        ) {
            return false;
        }
        $data = $error->get_error_data();
        $status = is_array( $data ) ? (int) ( $data['status'] ?? 0 ) : 0;
        if ( in_array( $status, [ 408, 425, 429 ], true ) || $status >= 500 ) {
            return true;
        }
        return in_array(
            $code,
            [
                'http_request_failed',
                'fotogenic_ai_request_failed',
                'fotogenic_ai_invalid_response',
                'fotogenic_ai_download_failed',
                'job_fetch_failed',
            ],
            true
        );
    }

}
