<?php

namespace Fotogenic_AI;

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

/**
 * Background worker: finalize a run (fetch jobs, save attachments, update Logger).
 * Runs on fotogenic_ai_finalize_run hook. Uses lock to prevent concurrent finalizers per run_id.
 */
final class Background_Finalizer {
    private static ?Background_Finalizer $instance = null;
    private Generate_Job_Finalizer $job_finalizer;
    private const BACKOFF_DELAYS_DEFAULT = [ 10, 15, 25, 40, 60 ];
    private const MAX_FINALIZE_ATTEMPTS_DEFAULT = 30;
    private const JOB_PROCESSING_LOCK_TTL = 600;
    private const RUN_PROCESSING_LOCK_TTL = 600;
    private const FINALIZE_META_TERMINAL_ERRORS_KEY = '__terminal_errors';
	private const FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY = '__terminal_output_counts';
    private bool $registered = false;

    private function __construct() {
        $this->job_finalizer = new Generate_Job_Finalizer();
    }

    public static function instance(): Background_Finalizer {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Register the hook. Call once from Rest or Plugin init.
     * Accepts 1 arg (array from WP-Cron) or 2 args (run_id, user_id from Action Scheduler).
     */
    public function register(): void {
		if ( $this->registered || ! Background_Runtime_State::is_enabled() ) {
            return;
        }
        $this->registered = true;

        add_action( Background_Scheduler::HOOK_FINALIZE, [ $this, 'handle_finalize' ], 10, 2 );
        Background_Scheduler::instance()->register();
    }

	public function deactivation_cleanup(): void {
		remove_action( Background_Scheduler::HOOK_FINALIZE, [ $this, 'handle_finalize' ], 10 );
		$this->registered = false;
		Background_Scheduler::instance()->deactivation_cleanup();
	}

	public function activation_cleanup(): void {
		$this->registered = false;
		$this->register();
	}

    /**
     * Handle scheduled finalization.
     * WP-Cron passes one argument: [ 'run_id' => string, 'user_id' => int ].
     * Action Scheduler may pass two arguments: run_id, user_id.
     *
     * @param array|string $args_or_run_id [ 'run_id' => string, 'user_id' => int ] or run_id string.
     * @param int|null     $user_id        User ID when called with 2 args.
     * @return void
     */
    public function handle_finalize( $args_or_run_id, $user_id = null ): void {
		if ( ! Background_Runtime_State::is_enabled() ) {
			return;
		}
        if ( is_array( $args_or_run_id ) ) {
            $run_id = isset( $args_or_run_id['run_id'] ) ? trim( (string) $args_or_run_id['run_id'] ) : '';
            $user_id = (int) ( $args_or_run_id['user_id'] ?? 0 );
        } else {
            $run_id = trim( (string) $args_or_run_id );
            $user_id = null !== $user_id ? (int) $user_id : 0;
        }
        if ( '' === $run_id || $user_id <= 0 ) {
            return;
        }
        $logger = Logger::instance();

        $lock = Background_Lock::instance();
        $run_lock_key = $this->get_run_lock_key( $run_id );
        $run_lock_token = $lock->acquire( $run_lock_key, self::RUN_PROCESSING_LOCK_TTL );
        $result = null;
        if ( false !== $run_lock_token ) {
            try {
                $result = $this->finalize_run( $run_id, $user_id, $run_lock_token );
            } finally {
                $lock->release( $run_lock_key, $run_lock_token );
            }
        }

        if ( null === $result ) {
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'reason'  => 'lock_not_acquired',
            ] );
            Background_Scheduler::instance()->schedule_finalize( $run_id, $user_id, 10 );
            return;
        }
        if ( true === $result ) {
            return;
        }
        if ( is_array( $result ) && ! empty( $result['terminalize_stale_run'] ) ) {
            $terminalized = Workflow_Start_Coordinator::terminalize_stale_run( $run_id );
            if ( $terminalized ) {
				$fresh = $logger->get_run_row( $run_id );
				$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $fresh );
				if ( empty( $settlement['settled'] ) ) {
					Background_Scheduler::instance()->schedule_finalize(
						$run_id,
						$user_id,
						max( 1, (int) ( $settlement['retryIn'] ?? 15 ) )
					);
				} else {
					Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
				}
            }
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
				'reason'  => $terminalized ? 'stale_no_job_start' : 'no_jobs',
            ] );
            return;
        }
        if ( is_array( $result ) && ! empty( $result['reschedule_in'] ) ) {
            $delay = (int) $result['reschedule_in'];
            if ( $delay > 0 ) {
                $scheduled = Background_Scheduler::instance()->schedule_finalize( $run_id, $user_id, $delay );
                if ( ! $scheduled ) {
                    $logger->log_finalize_worker( 'finalize_worker_exit', [
                        'run_id'  => $run_id,
                        'user_id' => $user_id,
                        'reason'  => 'reschedule_failed_recovery_queued',
                    ] );
                }
            }
        }
    }

    /**
     * Run finalization for one run. Called with lock held.
     * Returns true when run is final (completed/failed/timeout/stale), or array with reschedule_in seconds.
     *
     * @param string $run_id  Run ID.
     * @param int    $user_id Expected owner.
     * @return true|array{ reschedule_in?: int, terminalize_stale_run?: bool }|null
     */
    private function finalize_run( string $run_id, int $user_id, string $run_lock_token ) {
        $logger = Logger::instance();
        if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
            return [ 'reschedule_in' => 10 ];
        }
        $row = $logger->get_run_row( $run_id );
        if ( empty( $row ) ) {
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'reason'  => 'run_row_empty',
            ] );
            return true;
        }
        $owner_id = (int) ( $row['user_id'] ?? 0 );
        if ( $owner_id !== $user_id ) {
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'reason'  => 'owner_mismatch',
            ] );
            return true;
        }

		$final_status = (string) ( $row['final_status'] ?? '' );
		if ( in_array( $final_status, Workflow_Request_Store::QUARANTINE_STATUSES, true ) ) {
			$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $row );
			if ( empty( $settlement['settled'] ) ) {
				return [ 'reschedule_in' => max( 1, (int) ( $settlement['retryIn'] ?? 15 ) ) ];
			}
			Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
			$logger->log_finalize_worker( 'finalize_worker_exit', [
				'run_id'  => $run_id,
				'user_id' => $user_id,
				'reason'  => 'upstream_reconciliation_unresolved_settled',
				'status'  => $final_status,
			] );
			return true;
		}
		if ( in_array( $final_status, [ 'completed', 'partial', 'failed', 'timeout', 'stale', 'stopped' ], true ) ) {
			$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $row );
			if ( empty( $settlement['settled'] ) ) {
				return [ 'reschedule_in' => max( 1, (int) ( $settlement['retryIn'] ?? 15 ) ) ];
			}
			Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'reason'  => 'already_final',
                'status'  => $final_status,
            ] );
            return true;
        }

		$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
        $job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
        if ( empty( $job_ids ) && in_array( $workflow, Workflow_Request_Store::WORKFLOWS, true ) ) {
            return [ 'terminalize_stale_run' => true ];
        }

        $started_at = (string) ( $row['started_at'] ?? '' );
        $started_ts = $started_at ? strtotime( $started_at ) : 0;
        $elapsed = $started_ts > 0 ? time() - $started_ts : 0;
        $max_elapsed = $logger->get_generation_timeout_seconds( $workflow );
		if ( $elapsed > $max_elapsed ) {
            if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
			$finalized = $logger->finalize_run( $run_id, 'timeout', [
                'error_code'    => 'timeout',
                'error_message' => __( 'Generation timed out.', 'fotogenic-ai' ),
			] );
			if ( false === $finalized ) {
				return [ 'reschedule_in' => 10 ];
			}
			$this->clear_active_job_if_run( $user_id, $run_id );
			$fresh = $logger->get_run_row( $run_id );
			$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $fresh );
			$logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'elapsed' => $elapsed,
				'reason'  => 'timeout',
			] );
			if ( empty( $settlement['settled'] ) ) {
				return [ 'reschedule_in' => max( 1, (int) ( $settlement['retryIn'] ?? 15 ) ) ];
			}
			Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
			return true;
        }

        if ( empty( $job_ids ) ) {
            $logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'  => $run_id,
                'user_id' => $user_id,
                'reason'  => 'no_jobs',
            ] );
            return true;
        }

        $attempt = (int) ( $row['finalize_attempts'] ?? 0 );

        $attachments_map = [];
        if ( ! empty( $row['attachments_map'] ) ) {
            $decoded = json_decode( (string) $row['attachments_map'], true );
            if ( is_array( $decoded ) ) {
				$attachments_map = $this->filter_attachment_map_for_jobs( $decoded, $job_ids, $workflow );
            }
        }

        $finalize_attempts = (int) ( $row['finalize_attempts'] ?? 0 );
        $finalize_meta = [];
        $terminal_errors = [];
		$terminal_output_counts = [];
		$campaign_output_errors = Generate_Campaign_Admission::output_errors_from_finalize_meta( $row['finalize_meta'] ?? null );
		$campaign_output_errors_observed = false;
		$completed_job_ids = [];
        if ( ! empty( $row['finalize_meta'] ) ) {
            $decoded = json_decode( (string) $row['finalize_meta'], true );
            if ( is_array( $decoded ) ) {
				$completed_job_ids = Generate_Output_Map::completed_job_ids_from_meta( $decoded, $job_ids );
                $terminal_errors = $this->filter_terminal_errors_for_jobs(
                    $decoded[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] ?? [],
                    $job_ids
                );
				$terminal_output_counts = $this->filter_terminal_output_counts_for_jobs(
					$decoded[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] ?? [],
					$job_ids
				);
                unset( $decoded[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] );
				unset( $decoded[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] );
                $finalize_meta = $this->filter_finalize_attempts_for_jobs( $decoded, $job_ids );
            }
        }

		$product_title = $this->resolve_product_title( $row );
		$run_target = (string) ( $row['run_target'] ?? '' );
		$subject_id = (int) ( $row['subject_id'] ?? 0 );
		$product_id = 'product' === $run_target ? $subject_id : 0;
        $active_job = Job_Manager::instance()->get_active_job_by_run( $user_id, $run_id );
        $scene_description = is_array( $active_job )
            ? trim( (string) ( $active_job['scene_description'] ?? '' ) )
            : '';
        $quality = (string) ( $row['quality'] ?? '' );
        $context = [
            'run_id'           => $run_id,
            'user_id'          => $user_id,
            'workflow'         => $workflow,
            'request_id'       => (string) ( $row['request_id'] ?? '' ),
            'run_target'       => $run_target,
            'subject_id'       => $subject_id,
            'product_id'       => $product_id,
            'product_title'    => $product_title,
            'quality'          => $quality,
            'scene_description' => $scene_description,
            'source_attachment_id'  => (int) ( $row['source_attachment_id'] ?? 0 ),
            'source_attachment_ids' => $this->decode_id_list( $row['source_attachment_ids'] ?? [] ),
            'expected_output_count' => max( 1, (int) ( $row['expected_output_count'] ?? count( $job_ids ) ) ),
            'estimated_cost'         => max( 0, (float) ( $row['estimated_cost'] ?? 0 ) ),
            'run_item_uuid'          => (string) ( $row['run_item_uuid'] ?? '' ),
            'campaign_group_uuid'    => (string) ( $row['campaign_group_uuid'] ?? '' ),
            'item_ordinal'           => max( 0, (int) ( $row['item_ordinal'] ?? 0 ) ),
            'target_type'            => sanitize_key( (string) ( $row['target_type'] ?? '' ) ),
            'parent_product_id'      => max( 0, (int) ( $row['parent_product_id'] ?? 0 ) ),
            'variation_id'           => max( 0, (int) ( $row['variation_id'] ?? 0 ) ),
            'target_snapshot'        => (string) ( $row['target_snapshot'] ?? '' ),
        ];

        $attempt = $finalize_attempts;
        if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
            return [ 'reschedule_in' => 10 ];
        }
		if ( false === $logger->update_run( $run_id, [
			'finalize_attempts' => $attempt + 1,
		] ) ) {
			return [ 'reschedule_in' => 10 ];
		}

        $pending = false;
        $failed_job_ids = [];
        $max_asset_attempts = $this->job_finalizer->get_max_asset_attempts( $workflow );
        $expected_output_count = max( 1, (int) ( $row['expected_output_count'] ?? count( $job_ids ) ) );

        foreach ( $job_ids as $job_id ) {
			if ( in_array( $job_id, $completed_job_ids, true ) && Generate_Output_Map::has_job_outputs( $attachments_map, $job_id ) ) {
                unset( $terminal_errors[ $job_id ] );
                continue;
            }
            if ( isset( $terminal_errors[ $job_id ] ) ) {
                $failed_job_ids[] = $job_id;
                continue;
            }
            $job_attempts = (int) ( $finalize_meta[ $job_id ] ?? 0 );
            if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
            $job_lock_token = $this->claim_job_processing( $job_id );
            if ( false === $job_lock_token ) {
                $pending = true;
                continue;
            }
            $leases_valid = false;
            try {
                $result = $this->job_finalizer->finalize_job( $job_id, $context );
                $leases_valid = Background_Lock::instance()->renew( $this->get_job_lock_key( $job_id ), $job_lock_token, self::JOB_PROCESSING_LOCK_TTL )
                    && $this->renew_run_processing( $run_id, $run_lock_token );
            } finally {
                $this->release_job_processing( $job_id, $job_lock_token );
            }
            if ( ! $leases_valid ) {
                return [ 'reschedule_in' => 10 ];
            }
            $status = (string) ( $result['status'] ?? '' );
			$counts = $this->normalize_terminal_output_counts( $result['terminal_output_counts'] ?? [] );
			if ( ! empty( $counts ) ) {
				$terminal_output_counts[ $job_id ] = $counts;
			}
			if ( array_key_exists( 'terminal_output_errors', $result ) ) {
				$campaign_output_errors = Generate_Campaign_Admission::normalize_output_errors(
					$result['terminal_output_errors'],
					Generate_Campaign_Admission::from_finalize_meta( $row['finalize_meta'] ?? null )
				);
				$campaign_output_errors_observed = true;
			}
			$result_map = Generate_Output_Map::normalize( $result['attachments_map'] ?? [], $job_ids, $workflow );
			if ( ! empty( $result_map ) ) {
				$attachments_map = Generate_Output_Map::replace_job_slice( $attachments_map, $job_id, $result_map, $job_ids, $workflow );
			}
            if ( 'completed' === $status && Generate_Output_Map::has_job_outputs( $attachments_map, $job_id ) ) {
				$completed_job_ids[] = $job_id;
				$completed_job_ids = array_values( array_unique( $completed_job_ids ) );
                $finalize_meta[ $job_id ] = $job_attempts + 1;
                continue;
            }
            if ( 'not_ready' === $status ) {
                $finalize_meta[ $job_id ] = $job_attempts + 1;
                if ( $job_attempts + 1 >= $max_asset_attempts ) {
                    $failed_job_ids[] = $job_id;
					$error_code = substr( sanitize_key( (string) ( $result['error_code'] ?? 'ASSET_NOT_READY_TIMEOUT' ) ), 0, 64 );
                    $terminal_errors[ $job_id ] = '' !== $error_code ? $error_code : 'asset_not_ready_timeout';
                    if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                        return [ 'reschedule_in' => 10 ];
                    }
                } else {
                    $pending = true;
                }
                continue;
            }
            $failed_job_ids[] = $job_id;
            $finalize_meta[ $job_id ] = $job_attempts + 1;
			$error_code = substr( sanitize_key( (string) ( $result['error_code'] ?? 'job_finalize_failed' ) ), 0, 64 );
            $terminal_errors[ $job_id ] = '' !== $error_code ? $error_code : 'job_finalize_failed';
            if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
        }

        if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
            return [ 'reschedule_in' => 10 ];
        }
        // Read-merge-write: avoid overwriting attachments_map/finalize_meta written by poll or a prior worker tick.
        $fresh = $logger->get_run_row( $run_id );
		if ( ! empty( $fresh['attachments_map'] ) ) {
			$decoded = json_decode( (string) $fresh['attachments_map'], true );
			if ( is_array( $decoded ) ) {
				$decoded = $this->filter_attachment_map_for_jobs( $decoded, $job_ids, $workflow );
				$attachments_map = Generate_Output_Map::normalize(
					array_merge( $decoded, $attachments_map ),
					$job_ids,
					$workflow
				);
			}
		}
        if ( ! empty( $fresh['finalize_meta'] ) ) {
            $decoded = json_decode( (string) $fresh['finalize_meta'], true );
            if ( is_array( $decoded ) ) {
				$completed_job_ids = array_values(
					array_unique(
						array_merge( $completed_job_ids, Generate_Output_Map::completed_job_ids_from_meta( $decoded, $job_ids ) )
					)
				);
                $fresh_terminal_errors = $this->filter_terminal_errors_for_jobs(
                    $decoded[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] ?? [],
                    $job_ids
                );
                $terminal_errors = array_merge( $fresh_terminal_errors, $terminal_errors );
				$terminal_output_counts = array_merge(
					$this->filter_terminal_output_counts_for_jobs( $decoded[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] ?? [], $job_ids ),
					$terminal_output_counts
				);
                unset( $decoded[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] );
				unset( $decoded[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] );
                foreach ( $this->filter_finalize_attempts_for_jobs( $decoded, $job_ids ) as $jid => $cnt ) {
                    $cnt = (int) $cnt;
                    $current = (int) ( $finalize_meta[ $jid ] ?? 0 );
                    if ( $cnt > $current ) {
                        $finalize_meta[ $jid ] = $cnt;
                    }
                }
            }
        }
		if ( ! $campaign_output_errors_observed ) {
			$campaign_output_errors = Generate_Campaign_Admission::output_errors_from_finalize_meta( $fresh['finalize_meta'] ?? null );
		}

        foreach ( array_keys( $terminal_errors ) as $job_id ) {
			if ( in_array( $job_id, $completed_job_ids, true ) && Generate_Output_Map::has_job_outputs( $attachments_map, $job_id ) ) {
                unset( $terminal_errors[ $job_id ] );
            }
        }
        $failed_job_ids = array_values( array_keys( $terminal_errors ) );
        $stored_finalize_meta = $finalize_meta;
        if ( ! empty( $terminal_errors ) ) {
            $stored_finalize_meta[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] = $terminal_errors;
        }
		if ( ! empty( $terminal_output_counts ) ) {
			$stored_finalize_meta[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] = $terminal_output_counts;
		}
		$stored_finalize_meta = Generate_Output_Map::with_completed_job_ids( $stored_finalize_meta, $completed_job_ids, $job_ids );
		$stored_finalize_meta = Generate_Campaign_Admission::carry_forward( $stored_finalize_meta, $fresh['finalize_meta'] ?? $row['finalize_meta'] ?? null );
		$stored_finalize_meta = Generate_Campaign_Admission::with_output_errors( $stored_finalize_meta, $campaign_output_errors );

        if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
            return [ 'reschedule_in' => 10 ];
        }
		if ( false === $logger->update_run( $run_id, [
            'attachments_map'         => wp_json_encode( $attachments_map ),
            'finalize_meta'           => wp_json_encode( $stored_finalize_meta ),
            'last_api_request_at'     => gmdate( 'Y-m-d H:i:s' ),
		] ) ) {
			return [ 'reschedule_in' => 10 ];
		}

        $attachment_ids = Generate_Output_Map::attachment_ids( $attachments_map );
		$resolved_job_ids = array_values( array_unique( array_merge( $completed_job_ids, array_keys( $terminal_errors ) ) ) );
		$all_done = ! $pending && count( array_intersect( $resolved_job_ids, $job_ids ) ) === count( $job_ids );
        if ( $all_done ) {
            $is_partial = ! empty( $attachment_ids ) && (
                count( $attachment_ids ) !== $expected_output_count
                || ! empty( $failed_job_ids )
            );
            $final_status = empty( $attachment_ids ) ? 'failed' : ( $is_partial ? 'partial' : 'completed' );
            if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
			if ( false === $logger->update_run( $run_id, [
                'images_count'   => count( $attachment_ids ),
                'attachment_id'  => (int) ( $attachment_ids[0] ?? 0 ),
			] ) ) {
				return [ 'reschedule_in' => 10 ];
			}
			if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
			$terminal_error_code = 'completed' === $final_status
				? ''
				: $this->primary_terminal_error_code( $job_ids, $terminal_errors, $stored_finalize_meta, $final_status );
			$finalized = $logger->finalize_run( $run_id, $final_status, [
                'error_code'    => $terminal_error_code,
                'error_message' => $is_partial ? __( 'Some generated images could not be saved.', 'fotogenic-ai' ) : '',
			] );
			if ( false === $finalized ) {
				return [ 'reschedule_in' => 10 ];
			}
			$this->clear_active_job_if_run( $user_id, $run_id );
			$fresh = $logger->get_run_row( $run_id );
			$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $fresh );
			$logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'   => $run_id,
                'user_id'  => $user_id,
                'attempt'  => $attempt + 1,
                'elapsed'  => $elapsed,
				'reason'   => $final_status,
			] );
			if ( empty( $settlement['settled'] ) ) {
				return [ 'reschedule_in' => max( 1, (int) ( $settlement['retryIn'] ?? 15 ) ) ];
			}
			Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
			return true;
        }

        $backoff_delays = apply_filters( 'fotogenic_ai_finalize_backoff_delays', self::BACKOFF_DELAYS_DEFAULT );
        $max_attempts   = (int) apply_filters( 'fotogenic_ai_finalize_max_attempts', self::MAX_FINALIZE_ATTEMPTS_DEFAULT );
        if ( $pending && $attempt + 1 < $max_attempts && $elapsed < $max_elapsed ) {
            $delays = is_array( $backoff_delays ) && ! empty( $backoff_delays ) ? $backoff_delays : self::BACKOFF_DELAYS_DEFAULT;
            $delay_index = min( $attempt, count( $delays ) - 1 );
            $delay = (int) ( $delays[ $delay_index ] ?? 60 );
            $delay = max( 5, min( 300, $delay ) );
            return [ 'reschedule_in' => $delay ];
        }

		if ( $pending ) {
            if ( ! $this->renew_run_processing( $run_id, $run_lock_token ) ) {
                return [ 'reschedule_in' => 10 ];
            }
			$finalized = $logger->finalize_run( $run_id, 'timeout', [
                'error_code'    => 'timeout',
                'error_message' => __( 'Generation timed out.', 'fotogenic-ai' ),
			] );
			if ( false === $finalized ) {
				return [ 'reschedule_in' => 10 ];
			}
			$this->clear_active_job_if_run( $user_id, $run_id );
			$fresh = $logger->get_run_row( $run_id );
			$settlement = Workflow_Credit_Settlement::settle_terminal_run( $logger, $fresh );
			$logger->log_finalize_worker( 'finalize_worker_exit', [
                'run_id'   => $run_id,
                'user_id'  => $user_id,
                'attempt'  => $attempt + 1,
                'elapsed'  => $elapsed,
				'reason'   => 'timeout_max_attempts',
			] );
			if ( empty( $settlement['settled'] ) ) {
				return [ 'reschedule_in' => max( 1, (int) ( $settlement['retryIn'] ?? 15 ) ) ];
			}
		}
		Background_Scheduler::instance()->unschedule_finalize( $run_id, $user_id );
		$this->clear_active_job_if_run( $user_id, $run_id );
        return true;
    }

    /**
     * @return string|false
     */
    private function claim_job_processing( string $job_id ) {
        if ( '' === $job_id ) {
            return false;
        }
        return Background_Lock::instance()->acquire( $this->get_job_lock_key( $job_id ), self::JOB_PROCESSING_LOCK_TTL );
    }

    private function release_job_processing( string $job_id, string $token ): void {
        if ( '' === $job_id || '' === $token ) {
            return;
        }
        Background_Lock::instance()->release( $this->get_job_lock_key( $job_id ), $token );
    }

    private function get_run_lock_key( string $run_id ): string {
        return 'run-finalization:' . $run_id;
    }

    private function get_job_lock_key( string $job_id ): string {
        return 'job-finalization:' . $job_id;
    }

    private function renew_run_processing( string $run_id, string $token ): bool {
        return Background_Lock::instance()->renew(
            $this->get_run_lock_key( $run_id ),
            $token,
            self::RUN_PROCESSING_LOCK_TTL
        );
    }

    private function clear_active_job_if_run( int $user_id, string $run_id ): void {
        if ( $user_id <= 0 || '' === $run_id ) {
            return;
        }
        $job_manager = Job_Manager::instance();
        $job_manager->clear_active_jobs_by_run( $user_id, $run_id );
    }

    private function normalize_job_ids( $job_ids ): array {
        if ( is_string( $job_ids ) ) {
            $trimmed = trim( $job_ids );
            if ( '' !== $trimmed && ( fotogenic_ai_str_starts_with( $trimmed, '[' ) || fotogenic_ai_str_starts_with( $trimmed, '{' ) ) ) {
                $decoded = json_decode( $trimmed, true );
                if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
                    $job_ids = $decoded;
                } else {
                    $job_ids = [ $job_ids ];
                }
            } else {
                $job_ids = [ $job_ids ];
            }
        }
        if ( ! is_array( $job_ids ) ) {
            return [];
        }
        $normalized = [];
        foreach ( $job_ids as $job_id ) {
            $job_id = trim( (string) $job_id );
            if ( '' !== $job_id ) {
                $normalized[] = $job_id;
            }
        }
        return array_values( array_unique( $normalized ) );
    }

	private function resolve_product_title( array $row ): string {
		$product_title = (string) ( $row['product_title'] ?? '' );
		if ( '' !== $product_title || 'media' !== (string) ( $row['run_target'] ?? '' ) ) {
			return $product_title;
		}

		$subject_id = absint( $row['subject_id'] ?? 0 );
		$attachment = $subject_id > 0 ? get_post( $subject_id ) : null;

		return $attachment instanceof \WP_Post ? (string) $attachment->post_title : '';
	}

    private function decode_id_list( $ids ): array {
        if ( is_string( $ids ) ) {
            $decoded = json_decode( $ids, true );
            $ids = is_array( $decoded ) ? $decoded : [];
        }
        if ( ! is_array( $ids ) ) {
            return [];
        }
        return array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
    }

    private function filter_attachment_map_for_jobs( array $attachments_map, array $job_ids, string $workflow ): array {
		return Generate_Output_Map::normalize( $attachments_map, $job_ids, sanitize_key( $workflow ) );
    }

    private function filter_finalize_attempts_for_jobs( array $finalize_meta, array $job_ids ): array {
        $filtered = [];
        foreach ( $job_ids as $job_id ) {
            if ( array_key_exists( $job_id, $finalize_meta ) && is_numeric( $finalize_meta[ $job_id ] ) ) {
                $filtered[ $job_id ] = max( 0, (int) $finalize_meta[ $job_id ] );
            }
        }
        return $filtered;
    }

    private function filter_terminal_errors_for_jobs( $terminal_errors, array $job_ids ): array {
        if ( ! is_array( $terminal_errors ) ) {
            return [];
        }
        $filtered = [];
        foreach ( $job_ids as $job_id ) {
            if ( ! array_key_exists( $job_id, $terminal_errors ) ) {
                continue;
            }
			$error_code = substr( sanitize_key( (string) $terminal_errors[ $job_id ] ), 0, 64 );
            $filtered[ $job_id ] = '' !== $error_code ? $error_code : 'job_finalize_failed';
        }
        return $filtered;
    }

	private function primary_terminal_error_code( array $job_ids, array $terminal_errors, array $finalize_meta, string $fallback ): string {
		$campaign_errors = Generate_Campaign_Admission::output_errors_from_finalize_meta( $finalize_meta );
		$campaign_code = Generate_Campaign_Admission::primary_output_error_code(
			$campaign_errors,
			Generate_Campaign_Admission::from_finalize_meta( $finalize_meta )
		);
		if ( '' !== $campaign_code ) {
			return $campaign_code;
		}
		foreach ( $job_ids as $job_id ) {
			$error_code = substr( sanitize_key( (string) ( $terminal_errors[ $job_id ] ?? '' ) ), 0, 64 );
			if ( '' !== $error_code ) {
				return $error_code;
			}
		}

		$fallback = substr( sanitize_key( $fallback ), 0, 64 );
		return '' !== $fallback ? $fallback : 'failed';
	}

	/** @return array<string, array{requested:int,completed:int,failed:int}> */
	private function filter_terminal_output_counts_for_jobs( $counts, array $job_ids ): array {
		if ( ! is_array( $counts ) ) {
			return [];
		}
		$filtered = [];
		foreach ( $job_ids as $job_id ) {
			$normalized = $this->normalize_terminal_output_counts( $counts[ $job_id ] ?? [] );
			if ( ! empty( $normalized ) ) {
				$filtered[ $job_id ] = $normalized;
			}
		}
		return $filtered;
	}

	/** @return array{requested:int,completed:int,failed:int} */
	private function normalize_terminal_output_counts( $value ): array {
		if ( ! is_array( $value ) ) {
			return [];
		}
		$requested = $value['requested'] ?? null;
		$completed = $value['completed'] ?? null;
		$failed    = $value['failed'] ?? null;
		if ( ! is_int( $requested ) || ! is_int( $completed ) || ! is_int( $failed ) || $requested < 1 || $completed < 0 || $failed < 0 || $completed + $failed !== $requested ) {
			return [];
		}
		return [ 'requested' => $requested, 'completed' => $completed, 'failed' => $failed ];
	}
}
