<?php
/**
 * Generation orchestration service.
 *
 * @package AIAltTextGenerator
 * @since 1.4.0
 */

namespace AIAltTextGenerator;

use AIAltTextGenerator\Providers\ProviderInterface;
use AIAltTextGenerator\Providers\VisionHelper;

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

/**
 * Coordinates provider calls, validation, persistence, and logging.
 *
 * @since 1.4.0
 */
class GenerationService {

	/**
	 * @param array<string, mixed> $runtime Optional bulk/runtime overrides (timeout, skip_rewrite, etc.).
	 * @return array{status: string, alt_text?: string, message?: string, code?: string, retryable?: bool}
	 */
	public static function generateForAttachment( int $attachment_id, bool $force = false, array $runtime = [] ): array {
		if ( AttachmentMeta::isDecorative( $attachment_id ) ) {
			return [
				'status'    => 'skipped',
				'message'   => __( 'Image is marked decorative.', 'wp-powersuite' ),
				'code'      => 'decorative',
				'retryable' => false,
			];
		}

		if ( Usage::isLimitReached() ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'Monthly generation limit reached.', 'wp-powersuite' ),
				'code'      => GenerationError::CODE_QUOTA,
				'retryable' => false,
			];
		}

		$settings = Settings::get();
		if ( ! Settings::hasApiKey() ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'API key is not configured for the selected provider.', 'wp-powersuite' ),
				'code'      => GenerationError::CODE_API_KEY,
				'retryable' => false,
			];
		}

		if ( ! FeatureGate::isProviderAllowed( (string) ( $settings['provider'] ?? 'openai' ) ) ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'This AI provider requires a Pro license.', 'wp-powersuite' ),
				'code'      => 'provider_locked',
				'retryable' => false,
			];
		}

		$existing = self::currentAltText( $attachment_id );
		if ( ! $force && $existing !== '' && empty( $settings['overwrite_existing'] ) ) {
			return [
				'status'    => 'skipped',
				'message'   => __( 'Image already has alt text.', 'wp-powersuite' ),
				'code'      => 'has_alt',
				'retryable' => false,
			];
		}

		$filter_block = ImageFilter::blockingReason( $attachment_id, $settings );
		if ( $filter_block !== null ) {
			$filter_block['code']      = ( $filter_block['status'] ?? '' ) === 'skipped'
				? 'filtered'
				: GenerationError::CODE_IMAGE_ACCESS;
			$filter_block['retryable'] = false;

			return $filter_block;
		}

		$file = get_attached_file( $attachment_id, true );
		if ( ! is_string( $file ) || $file === '' ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'Image file not found.', 'wp-powersuite' ),
				'code'      => GenerationError::CODE_IMAGE_ACCESS,
				'retryable' => false,
			];
		}

		$skip_metadata = ! empty( $runtime['skip_metadata'] );
		if ( ! $skip_metadata && ! empty( $settings['generate_missing_metadata'] ) && is_file( $file ) ) {
			$meta = wp_get_attachment_metadata( $attachment_id );
			if ( ! is_array( $meta ) || $meta === [] ) {
				require_once ABSPATH . 'wp-admin/includes/image.php';
				$skip_conversion = static function ( bool $skip, int $id ) use ( $attachment_id ): bool {
					return $skip || $id === $attachment_id;
				};
				add_filter( 'wp_powersuite_skip_avif_conversion', $skip_conversion, 10, 2 );
				wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
				remove_filter( 'wp_powersuite_skip_avif_conversion', $skip_conversion, 10 );
			}
		}

		// Prefer a WP intermediate for vision — full originals commonly exceed shared-host AJAX limits.
		$vision_file = VisionHelper::resolveFileForVision( $attachment_id, $file );

		$api_key = Settings::getApiKey( $settings );
		$options = self::buildOptions( $attachment_id, $settings, $api_key, $runtime );
		$start   = microtime( true );
		$result  = self::provider()->generate( $vision_file, $options );
		$elapsed = ( microtime( true ) - $start ) * 1000;
		if ( empty( $runtime['bulk'] ) ) {
			Performance::record(
				'generate',
				$elapsed,
				[
					'attachment_id' => $attachment_id,
					'provider'      => self::provider()->getId(),
				]
			);
			Diagnostics::debug(
				'generate_complete',
				[
					'attachment_id' => $attachment_id,
					'provider'      => self::provider()->getId(),
					'model'         => (string) ( $settings['model'] ?? '' ),
					'duration_ms'   => (int) round( $elapsed ),
					'success'       => ! is_wp_error( $result ),
				]
			);
		}

		if ( is_wp_error( $result ) ) {
			$classified = GenerationError::fromWpError( $result );
			self::logFailure( $attachment_id, $existing, $result, $settings, $classified );
			AttachmentMeta::storeSuggestion( $attachment_id, '', 'failed' );

			/**
			 * Fires when AI alt text generation fails.
			 *
			 * @param int       $attachment_id Attachment ID.
			 * @param \WP_Error $result        Error object.
			 */
			do_action( 'wpps_ai_alt_text_failed', $attachment_id, $result );

			return [
				'status'    => 'failed',
				'message'   => $classified['message'],
				'code'      => $classified['code'],
				'retryable' => $classified['retryable'],
			];
		}

		$max_chars = AltTextOutput::effectiveMaxCharsForGeneration( $settings );
		$alt_text  = self::validateOutput( (string) ( $result['alt_text'] ?? '' ), $max_chars );
		if ( $alt_text === '' ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'Generated alt text failed validation.', 'wp-powersuite' ),
				'code'      => 'empty_response',
				'retryable' => true,
			];
		}

		if ( empty( $runtime['skip_rewrite'] ) ) {
			$rewritten = self::validateOutput( AltRewriter::apply( $alt_text, $settings ), $max_chars );
			// Never abort a successful generation because the optional rewrite pass was unusable.
			$alt_text = $rewritten !== '' ? $rewritten : $alt_text;
		}

		$context  = is_array( $options['context'] ?? null ) ? $options['context'] : [];
		$before_kw = $alt_text;
		$alt_text  = self::ensureSeoKeywords( $alt_text, $context, $max_chars, $attachment_id );
		$alt_text  = self::validateOutput( $alt_text, $max_chars );
		if ( $alt_text === '' ) {
			return [
				'status'    => 'failed',
				'message'   => __( 'Generated alt text failed validation.', 'wp-powersuite' ),
				'code'      => 'empty_response',
				'retryable' => true,
			];
		}

		$keyword_candidates = self::seoKeywordCandidates( $context );
		$keywords_applied   = [];
		foreach ( $keyword_candidates as $candidate ) {
			if ( str_contains( strtolower( $alt_text ), strtolower( $candidate ) ) ) {
				$keywords_applied[] = $candidate;
			}
		}

		$tokens = (int) ( $result['tokens'] ?? 0 );
		Usage::record( $tokens, (string) ( $settings['model'] ?? Models::DEFAULT ) );

		Logger::log(
			[
				'attachment_id' => $attachment_id,
				'filename'      => basename( $file ),
				'old_alt'       => $existing,
				'new_alt'       => $alt_text,
				'provider'      => self::provider()->getId(),
				'model'         => (string) ( $settings['model'] ?? Models::DEFAULT ),
				'status'        => 'success',
				'tokens_used'   => $tokens,
			]
		);

		if ( ! empty( $settings['auto_approve_suggestions'] ) ) {
			AttachmentMeta::saveAltText( $attachment_id, $alt_text );
		} else {
			AttachmentMeta::storeSuggestion( $attachment_id, $alt_text, 'generated' );
		}

		if ( empty( $runtime['bulk'] ) ) {
			Scanner::invalidateCache();
		}

		return [
			'status'            => 'success',
			'alt_text'          => $alt_text,
			'old_alt'           => $existing,
			'keywords_applied'  => $keywords_applied,
			'keywords_injected' => $before_kw !== $alt_text && $keywords_applied !== [],
		];
	}

	/**
	 * Best-effort current alt text before generation (saved meta or pending suggestion).
	 */
	private static function currentAltText( int $attachment_id ): string {
		$saved = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
		if ( $saved !== '' ) {
			return $saved;
		}

		return AttachmentMeta::getSuggestion( $attachment_id );
	}

	/**
	 * @param array<string, mixed> $settings
	 * @param array<string, mixed> $runtime
	 * @return array<string, mixed>
	 */
	public static function buildOptions( int $attachment_id, ?array $settings = null, ?string $api_key = null, array $runtime = [] ): array {
		$settings = $settings ?? Settings::get();
		$api_key  = $api_key ?? Settings::getApiKey( $settings );

		$context = ContextHelper::getAttachmentHints( $attachment_id );

		if ( ! empty( $settings['use_woo_context'] ) || ! empty( $settings['use_seo_keyphrase'] ) || ! empty( $settings['use_post_title_keywords'] ) ) {
			$ctx = ContextHelper::getContext( $attachment_id );
			if ( ! empty( $settings['use_woo_context'] ) && $ctx['product_title'] !== '' ) {
				$context['product_title'] = $ctx['product_title'];
			}
			if ( ! empty( $settings['use_woo_context'] ) && $ctx['product_category'] !== '' ) {
				$context['product_category'] = $ctx['product_category'];
			}
			if ( ! empty( $settings['use_woo_context'] ) && $ctx['product_short_description'] !== '' ) {
				$context['product_short_description'] = $ctx['product_short_description'];
			}
			if ( ! empty( $settings['use_woo_context'] ) && $ctx['variation_attributes'] !== '' ) {
				$context['variation_attributes'] = $ctx['variation_attributes'];
			}
			if ( ( ! empty( $settings['use_seo_keyphrase'] ) || ! empty( $settings['use_post_title_keywords'] ) ) && $ctx['keyphrase'] !== '' ) {
				$context['keyphrase'] = $ctx['keyphrase'];
			}
			if ( $ctx['language'] !== '' ) {
				$context['language'] = $ctx['language'];
			}
		}

		if ( empty( $runtime['skip_duplicates'] ) ) {
			$duplicate = DuplicateDetector::getContextForAttachment( $attachment_id );
			if ( $duplicate !== null ) {
				$context['avoid_alt']       = (string) $duplicate['shared_alt'];
				$context['duplicate_count'] = (int) $duplicate['count'];
			}
		}

		/**
		 * Filter context passed to the AI provider.
		 *
		 * @param array<string, mixed> $context       Context array.
		 * @param int                  $attachment_id Attachment ID.
		 */
		$context = apply_filters( 'wpps_ai_alt_text_context', $context, $attachment_id );

		$timeout = Text::safeHttpTimeout( (int) ( $settings['request_timeout'] ?? 90 ) );
		if ( isset( $runtime['timeout'] ) ) {
			$timeout = Text::safeHttpTimeout( (int) $runtime['timeout'], 5 );
		}
		// Shared-host proxies (LiteSpeed) often hard-kill admin-ajax around 30–60s with HTTP 500.
		if ( ! empty( $runtime['bulk'] ) ) {
			$timeout = min( $timeout, 18 );
		}

		$max_dimension = ! empty( $runtime['bulk'] )
			? VisionHelper::BULK_MAX_DIMENSION
			: VisionHelper::MAX_DIMENSION;

		// Force JPEG conversion only when it likely saves time/size (WebP/AVIF/huge files).
		$force_jpeg = ! empty( $runtime['bulk'] ) && ! empty( $runtime['force_jpeg'] );

		return [
			'attachment_id'  => $attachment_id,
			'api_key'        => $api_key,
			'model'          => (string) ( $settings['model'] ?? Models::DEFAULT ),
			'max_chars'      => AltTextOutput::effectiveMaxCharsForGeneration( $settings ),
			'prompt_style'   => PromptStyles::sanitize( (string) ( $settings['prompt_style'] ?? 'concise' ) ),
			'language'       => (string) ( $settings['language'] ?? '' ),
			'custom_prompt'  => trim( (string) ( $settings['custom_prompt'] ?? '' ) ),
			'timeout'        => $timeout,
			'max_dimension'  => $max_dimension,
			'force_jpeg'     => $force_jpeg,
			'context'        => $context,
		];
	}

	public static function provider(): ProviderInterface {
		return ProviderRegistry::get();
	}

	/**
	 * Models often ignore soft keyword instructions — guarantee at least one provided phrase appears.
	 *
	 * @param array<string, mixed> $context
	 */
	public static function ensureSeoKeywords( string $alt_text, array $context, int $max_chars, int $attachment_id = 0 ): string {
		$alt_text = trim( $alt_text );
		if ( $alt_text === '' ) {
			return '';
		}

		$candidates = self::seoKeywordCandidates( $context );
		if ( $candidates === [] ) {
			return $alt_text;
		}

		$lower = strtolower( $alt_text );
		foreach ( $candidates as $keyword ) {
			if ( $keyword !== '' && str_contains( $lower, strtolower( $keyword ) ) ) {
				return $alt_text;
			}
		}

		$index   = $attachment_id > 0 ? ( $attachment_id % count( $candidates ) ) : 0;
		$keyword = $candidates[ $index ];
		$suffix  = ' — ' . $keyword;
		$combined = rtrim( $alt_text, " \t.,;:" ) . $suffix;

		if ( $max_chars > 0 && Text::length( $combined ) > $max_chars ) {
			$budget = $max_chars - Text::length( $suffix );
			if ( $budget < 24 ) {
				return Text::slice( $keyword, 0, max( 1, $max_chars ) );
			}
			$combined = rtrim( Text::slice( $alt_text, 0, $budget ), " \t.,;:" ) . $suffix;
		}

		return $combined;
	}

	/**
	 * @param array<string, mixed> $context
	 * @return list<string>
	 */
	public static function seoKeywordCandidates( array $context ): array {
		$candidates = [];
		$keyphrase  = trim( (string) ( $context['keyphrase'] ?? '' ) );
		if ( $keyphrase !== '' ) {
			$candidates[] = $keyphrase;
		}

		$bulk = trim( (string) ( $context['bulk_keywords'] ?? '' ) );
		if ( $bulk !== '' ) {
			foreach ( preg_split( '/\s*,\s*/', $bulk ) ?: [] as $part ) {
				$part = trim( (string) $part );
				if ( $part !== '' ) {
					$candidates[] = $part;
				}
			}
		}

		return array_values( array_unique( $candidates ) );
	}

	private static function validateOutput( string $alt_text, int $max_chars ): string {
		$alt_text = trim( $alt_text );
		if ( $alt_text === '' ) {
			return '';
		}

		// Strip surrounding quotes the model sometimes wraps around the answer.
		if (
			( str_starts_with( $alt_text, '"' ) && str_ends_with( $alt_text, '"' ) )
			|| ( str_starts_with( $alt_text, "'" ) && str_ends_with( $alt_text, "'" ) )
		) {
			$alt_text = trim( substr( $alt_text, 1, -1 ) );
		}

		$forbidden = [ 'image of', 'picture of', 'photo of', 'a photo of', 'an image of' ];
		$lower     = strtolower( $alt_text );
		foreach ( $forbidden as $phrase ) {
			if ( str_starts_with( $lower, $phrase . ' ' ) || $lower === $phrase ) {
				$alt_text = trim( substr( $alt_text, strlen( $phrase ) ) );
				$lower    = strtolower( $alt_text );
				break;
			}
		}

		if ( $max_chars > 0 && Text::length( $alt_text ) > $max_chars ) {
			$alt_text = Text::slice( $alt_text, 0, $max_chars - 1 ) . '…';
		}

		return sanitize_text_field( $alt_text );
	}

	/**
	 * @param array{code: string, message: string, retryable: bool, http_status: int}|null $classified
	 */
	private static function logFailure( int $attachment_id, string $old_alt, \WP_Error $error, array $settings, ?array $classified = null ): void {
		$file = get_attached_file( $attachment_id, true );
		Logger::log(
			[
				'attachment_id' => $attachment_id,
				'filename'      => $file ? basename( $file ) : '',
				'old_alt'       => $old_alt,
				'new_alt'       => '',
				'provider'      => self::provider()->getId(),
				'model'         => (string) ( $settings['model'] ?? Models::DEFAULT ),
				'status'        => 'failed',
				'error_message' => $classified['message'] ?? $error->get_error_message(),
			]
		);
	}
}
