<?php

namespace Fotogenic_AI;

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

/**
 * Reconcile terminal runs whose accepted upstream jobs may still be saveable.
 *
 * This owner never starts generation. It may only re-read an already accepted
 * job through Generate_Job_Finalizer and persist the resulting attachment.
 */
final class Generate_Terminal_Reconciler {
    private const FINALIZE_META_TERMINAL_ERRORS_KEY = '__terminal_errors';
	private const FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY = '__terminal_output_counts';
    private const JOB_LOCK_TTL = 600;
    private const TERMINAL_STATUSES = [ 'completed', 'partial', 'failed', 'timeout', 'stale' ];
    private const RETRYABLE_ERROR_CODES = [
        'asset_not_ready_timeout',
        'timeout',
        'job_not_ready',
        'missing_image_url',
        'http_request_failed',
        'fotogenic_ai_http_error',
        'fotogenic_ai_request_failed',
        'fotogenic_ai_invalid_response',
        'fotogenic_ai_download_failed',
        'job_fetch_failed',
    ];

    private Generate_Job_Finalizer $job_finalizer;

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

    /**
     * Whether at least one requested missing job is eligible for a non-billable
     * finalization check.
     */
    public function can_reconcile( array $row, array $requested_job_ids ): bool {
        $final_status = sanitize_key( (string) ( $row['final_status'] ?? '' ) );
        if ( ! in_array( $final_status, self::TERMINAL_STATUSES, true ) ) {
            return false;
        }

        $run_job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
        $requested_job_ids = $this->normalize_job_ids( $requested_job_ids );
        if ( empty( $requested_job_ids ) ) {
            $requested_job_ids = $run_job_ids;
        }
        $requested_job_ids = array_values( array_intersect( $requested_job_ids, $run_job_ids ) );
        if ( empty( $requested_job_ids ) ) {
            return false;
        }

		$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$attachments_map = $this->decode_attachment_map( $row['attachments_map'] ?? [], $run_job_ids, $workflow );
		$completed_job_ids = Generate_Output_Map::completed_job_ids_from_meta( $row['finalize_meta'] ?? [], $run_job_ids );
        $terminal_errors = $this->decode_terminal_errors( $row['finalize_meta'] ?? [], $run_job_ids );
        foreach ( $requested_job_ids as $job_id ) {
            if (
				! in_array( $job_id, $completed_job_ids, true )
                && $this->job_can_reconcile( $job_id, $row, $terminal_errors )
            ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Return missing jobs that are terminal and no longer recoverable.
     *
     * @return string[]
     */
    public function get_failed_job_ids( array $row, array $requested_job_ids ): array {
        $run_job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
        $requested_job_ids = $this->normalize_job_ids( $requested_job_ids );
        if ( empty( $requested_job_ids ) ) {
            $requested_job_ids = $run_job_ids;
        }
        $requested_job_ids = array_values( array_intersect( $requested_job_ids, $run_job_ids ) );
		$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$attachments_map = $this->decode_attachment_map( $row['attachments_map'] ?? [], $run_job_ids, $workflow );
		$completed_job_ids = Generate_Output_Map::completed_job_ids_from_meta( $row['finalize_meta'] ?? [], $run_job_ids );
        $terminal_errors = $this->decode_terminal_errors( $row['finalize_meta'] ?? [], $run_job_ids );
        $failed = [];
        foreach ( $requested_job_ids as $job_id ) {
            if (
				! in_array( $job_id, $completed_job_ids, true )
                && ! $this->job_can_reconcile( $job_id, $row, $terminal_errors )
            ) {
                $failed[] = $job_id;
            }
        }
        return $failed;
    }

    /**
     * Attempt one missing job while the caller owns the run-finalization lock.
     *
     * @return array<string, mixed> Fresh durable run row.
     */
    public function reconcile( array $row, int $user_id, array $requested_job_ids, string $run_lock_token ): array {
        $run_id = trim( (string) ( $row['run_id'] ?? '' ) );
        if (
            '' === $run_id
            || '' === $run_lock_token
            || $user_id <= 0
            || (int) ( $row['user_id'] ?? 0 ) !== $user_id
            || ! $this->can_reconcile( $row, $requested_job_ids )
        ) {
            return $row;
        }

        $run_job_ids = $this->normalize_job_ids( $row['job_ids'] ?? [] );
        $requested_job_ids = $this->normalize_job_ids( $requested_job_ids );
        if ( empty( $requested_job_ids ) ) {
            $requested_job_ids = $run_job_ids;
        }
        $requested_job_ids = array_values( array_intersect( $requested_job_ids, $run_job_ids ) );
		$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$attachments_map = $this->decode_attachment_map( $row['attachments_map'] ?? [], $run_job_ids, $workflow );
		$completed_job_ids = Generate_Output_Map::completed_job_ids_from_meta( $row['finalize_meta'] ?? [], $run_job_ids );
        $terminal_errors = $this->decode_terminal_errors( $row['finalize_meta'] ?? [], $run_job_ids );
        $job_id = '';
        foreach ( $requested_job_ids as $candidate_job_id ) {
            if (
				! in_array( $candidate_job_id, $completed_job_ids, true )
                && $this->job_can_reconcile( $candidate_job_id, $row, $terminal_errors )
            ) {
                $job_id = $candidate_job_id;
                break;
            }
        }
        if ( '' === $job_id ) {
            return $row;
        }

        $locks = Background_Lock::instance();
        $run_lock_key = $this->get_run_lock_key( $run_id );
        if ( ! $locks->renew( $run_lock_key, $run_lock_token, self::JOB_LOCK_TTL ) ) {
            return Logger::instance()->get_run_row( $run_id );
        }
        $job_lock_key = $this->get_job_lock_key( $job_id );
        $job_lock_token = $locks->acquire( $job_lock_key, self::JOB_LOCK_TTL );
        if ( false === $job_lock_token ) {
            return Logger::instance()->get_run_row( $run_id );
        }

        $leases_valid = false;
        try {
            $result = $this->job_finalizer->finalize_job(
                $job_id,
                $this->build_finalizer_context( $row, $user_id )
            );
            $leases_valid = $locks->renew( $job_lock_key, $job_lock_token, self::JOB_LOCK_TTL )
                && $locks->renew( $run_lock_key, $run_lock_token, self::JOB_LOCK_TTL );
        } finally {
            $locks->release( $job_lock_key, $job_lock_token );
        }
        if ( ! $leases_valid ) {
            return Logger::instance()->get_run_row( $run_id );
        }

        $logger = Logger::instance();
        $fresh = $logger->get_run_row( $run_id );
        if (
            empty( $fresh )
            || (int) ( $fresh['user_id'] ?? 0 ) !== $user_id
            || ! in_array( $job_id, $this->normalize_job_ids( $fresh['job_ids'] ?? [] ), true )
        ) {
            return $fresh;
        }

        $run_job_ids = $this->normalize_job_ids( $fresh['job_ids'] ?? [] );
		$workflow = sanitize_key( (string) ( $fresh['workflow'] ?? '' ) );
		$attachments_map = $this->decode_attachment_map( $fresh['attachments_map'] ?? [], $run_job_ids, $workflow );
        $finalize_meta = $this->decode_finalize_attempts( $fresh['finalize_meta'] ?? [], $run_job_ids );
		$completed_job_ids = Generate_Output_Map::completed_job_ids_from_meta( $fresh['finalize_meta'] ?? [], $run_job_ids );
        $terminal_errors = $this->decode_terminal_errors( $fresh['finalize_meta'] ?? [], $run_job_ids );
		$terminal_output_counts = $this->decode_terminal_output_counts( $fresh['finalize_meta'] ?? [], $run_job_ids );
		$campaign_output_errors = Generate_Campaign_Admission::output_errors_from_finalize_meta( $fresh['finalize_meta'] ?? null );
        $finalize_meta[ $job_id ] = (int) ( $finalize_meta[ $job_id ] ?? 0 ) + 1;

        $result_status = sanitize_key( (string) ( $result['status'] ?? '' ) );
		$result_error_code = substr( sanitize_key( (string) ( $result['error_code'] ?? '' ) ), 0, 64 );
		$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( $fresh['finalize_meta'] ?? null )
			);
		}
		$result_map = Generate_Output_Map::normalize( $result['attachments_map'] ?? [], $run_job_ids, $workflow );
		if ( ! empty( $result_map ) ) {
			$attachments_map = Generate_Output_Map::replace_job_slice( $attachments_map, $job_id, $result_map, $run_job_ids, $workflow );
		}
		$job_outputs_complete = 'completed' === $result_status && Generate_Output_Map::has_job_outputs( $attachments_map, $job_id );
		if ( $job_outputs_complete ) {
			$completed_job_ids[] = $job_id;
			$completed_job_ids = array_values( array_unique( $completed_job_ids ) );
		}
		if ( $job_outputs_complete ) {
			unset( $terminal_errors[ $job_id ] );
        } elseif ( 'not_ready' === $result_status ) {
            $terminal_errors[ $job_id ] = '' !== $result_error_code ? $result_error_code : 'job_not_ready';
        } else {
            $terminal_errors[ $job_id ] = '' !== $result_error_code ? $result_error_code : 'job_finalize_failed';
        }

        foreach ( array_keys( $terminal_errors ) as $terminal_job_id ) {
			if (
				in_array( $terminal_job_id, $completed_job_ids, true )
				&& Generate_Output_Map::has_job_outputs( $attachments_map, $terminal_job_id )
			) {
                unset( $terminal_errors[ $terminal_job_id ] );
            }
        }
        $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, $run_job_ids );
		$stored_finalize_meta = Generate_Campaign_Admission::carry_forward( $stored_finalize_meta, $fresh['finalize_meta'] ?? null );
		$stored_finalize_meta = Generate_Campaign_Admission::with_output_errors( $stored_finalize_meta, $campaign_output_errors );
        $attachment_ids = Generate_Output_Map::attachment_ids( $attachments_map );
		$projection_persisted = $logger->update_run(
            $run_id,
            [
				'attachments_map' => wp_json_encode( $attachments_map ),
				'finalize_meta'   => wp_json_encode( $stored_finalize_meta ),
				'images_count'    => count( $attachment_ids ),
				'attachment_id'   => (int) ( $attachment_ids[0] ?? 0 ),
            ]
        );
		if ( false === $projection_persisted ) {
			return $fresh;
		}

        $next = array_merge(
            $fresh,
            [
                'attachments_map' => wp_json_encode( $attachments_map ),
                'finalize_meta'   => wp_json_encode( $stored_finalize_meta ),
            ]
        );
        $expected_output_count = max( 1, (int) ( $fresh['expected_output_count'] ?? count( $run_job_ids ) ) );
        $missing_job_ids = array_values(
			array_filter(
				$run_job_ids,
				static fn( string $candidate_job_id ): bool => ! in_array( $candidate_job_id, $completed_job_ids, true )
			)
		);
		$finalization_pending = $this->can_reconcile( $next, $missing_job_ids );
        $all_completed = empty( $missing_job_ids ) && count( $attachment_ids ) === $expected_output_count;
		$campaign_error_code = Generate_Campaign_Admission::primary_output_error_code(
			Generate_Campaign_Admission::output_errors_from_finalize_meta( $stored_finalize_meta ),
			Generate_Campaign_Admission::from_finalize_meta( $stored_finalize_meta )
		);

        if ( $all_completed ) {
			$finalized = $logger->finalize_run(
                $run_id,
                'completed',
                [
                    'attachment_id' => (int) ( $attachment_ids[0] ?? 0 ),
                    'error_code'    => '',
                    'error_message' => '',
                ]
            );
			if ( false === $finalized ) {
				return $logger->get_run_row( $run_id );
			}
        } elseif ( 'completed' === $result_status && ! empty( $attachment_ids ) ) {
			$finalized = $logger->finalize_run(
                $run_id,
                'partial',
                [
                    'attachment_id' => (int) ( $attachment_ids[0] ?? 0 ),
					'error_code'    => $campaign_error_code ?: ( $result_error_code ?: 'partial' ),
                    'error_message' => __( 'Some generated images are still being finalized.', 'fotogenic-ai' ),
                ]
            );
			if ( false === $finalized ) {
				return $logger->get_run_row( $run_id );
			}
		} elseif ( 'failed' === $result_status && ! $finalization_pending ) {
			$finalized = $logger->finalize_run(
                $run_id,
                empty( $attachment_ids ) ? 'failed' : 'partial',
                [
                    'attachment_id' => (int) ( $attachment_ids[0] ?? 0 ),
					'error_code'    => $campaign_error_code ?: ( $result_error_code ?: ( empty( $attachment_ids ) ? 'failed' : 'partial' ) ),
                    'error_message' => empty( $attachment_ids )
                        ? __( 'Generation could not be finalized.', 'fotogenic-ai' )
                        : __( 'Some generated images could not be saved.', 'fotogenic-ai' ),
                ]
            );
			if ( false === $finalized ) {
				return $logger->get_run_row( $run_id );
			}
        }

        return $logger->get_run_row( $run_id );
    }

    private function job_can_reconcile( string $job_id, array $row, array $terminal_errors ): bool {
        if ( isset( $terminal_errors[ $job_id ] ) ) {
            return in_array( $terminal_errors[ $job_id ], self::RETRYABLE_ERROR_CODES, true );
        }
        $final_status = sanitize_key( (string) ( $row['final_status'] ?? '' ) );
        if ( in_array( $final_status, [ 'timeout', 'stale' ], true ) ) {
            return true;
        }
        $error_code = sanitize_key( (string) ( $row['error_code'] ?? '' ) );
        return in_array( $error_code, self::RETRYABLE_ERROR_CODES, true );
    }

    /** @return array<string, mixed> */
    private function build_finalizer_context( array $row, int $user_id ): array {
		$run_target = sanitize_key( (string) ( $row['run_target'] ?? '' ) );
		$subject_id = absint( $row['subject_id'] ?? 0 );
        return [
            'run_id'                 => (string) ( $row['run_id'] ?? '' ),
            'user_id'                => $user_id,
			'workflow'               => sanitize_key( (string) ( $row['workflow'] ?? '' ) ),
            'request_id'             => (string) ( $row['request_id'] ?? '' ),
			'run_target'             => $run_target,
			'subject_id'             => $subject_id,
			'product_id'             => 'product' === $run_target ? $subject_id : 0,
            'product_title'          => (string) ( $row['product_title'] ?? '' ),
            'quality'                => (string) ( $row['quality'] ?? '' ),
            'scene_description'      => '',
            'source_attachment_id'   => absint( $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'] ?? 1 ) ),
            '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'] ?? '' ),
        ];
    }

    /** @return array<string, int> */
    private function decode_attachment_map( $value, array $job_ids, string $workflow ): array {
        if ( is_string( $value ) ) {
            $decoded = json_decode( $value, true );
            $value = is_array( $decoded ) ? $decoded : [];
        }
        if ( ! is_array( $value ) ) {
            return [];
        }
		return Generate_Output_Map::normalize( $value, $job_ids, $workflow );
    }

    /** @return array<string, int> */
    private function decode_finalize_attempts( $value, array $job_ids ): array {
        $value = $this->decode_finalize_meta( $value );
        $attempts = [];
        foreach ( $job_ids as $job_id ) {
            if ( isset( $value[ $job_id ] ) && is_numeric( $value[ $job_id ] ) ) {
                $attempts[ $job_id ] = max( 0, (int) $value[ $job_id ] );
            }
        }
        return $attempts;
    }

    /** @return array<string, string> */
    private function decode_terminal_errors( $value, array $job_ids ): array {
        $value = $this->decode_finalize_meta( $value );
        $terminal_errors = $value[ self::FINALIZE_META_TERMINAL_ERRORS_KEY ] ?? [];
        if ( ! is_array( $terminal_errors ) ) {
            return [];
        }
        $filtered = [];
        foreach ( $job_ids as $job_id ) {
			$error_code = substr( sanitize_key( (string) ( $terminal_errors[ $job_id ] ?? '' ) ), 0, 64 );
            if ( '' !== $error_code ) {
                $filtered[ $job_id ] = $error_code;
            }
        }
        return $filtered;
    }

	/** @return array<string, array{requested:int,completed:int,failed:int}> */
	private function decode_terminal_output_counts( $value, array $job_ids ): array {
		$value = $this->decode_finalize_meta( $value );
		$stored = $value[ self::FINALIZE_META_TERMINAL_OUTPUT_COUNTS_KEY ] ?? [];
		if ( ! is_array( $stored ) ) {
			return [];
		}
		$filtered = [];
		foreach ( $job_ids as $job_id ) {
			$counts = $this->normalize_terminal_output_counts( $stored[ $job_id ] ?? [] );
			if ( ! empty( $counts ) ) {
				$filtered[ $job_id ] = $counts;
			}
		}
		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 ];
	}

    private function decode_finalize_meta( $value ): array {
        if ( is_string( $value ) ) {
            $decoded = json_decode( $value, true );
            $value = is_array( $decoded ) ? $decoded : [];
        }
        return is_array( $value ) ? $value : [];
    }

    /** @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 ) ) ) )
            : [];
    }

    /** @return string[] */
    private 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 [];
        }
        return array_values(
            array_unique(
                array_filter(
                    array_map( static fn( $job_id ): string => trim( (string) $job_id ), $job_ids )
                )
            )
        );
    }

    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;
    }
}
