<?php

/**
 * Thin wrapper around wp_ai_client_prompt() for Plugin Builder generation.
 *
 * @package LolaCore\PluginBuilder\Generation
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\PluginBuilder\Generation;

use LolaCore\Admin\Settings;
use LolaCore\I18n\ErrorMessages;
use LolaCore\LLM\ModelTokenBudget;
use LolaCore\LLM\Providers\DeepSeek\DeepSeekModelSupport;
use LolaCore\LLM\SelectedModelResolver;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;

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

/**
 * Resolves the configured model and executes text / JSON prompts.
 */
class AiClientHelper {

	/**
	 * Wait budget for a full plugin generation. The output budget is driven by
	 * ModelTokenBudget (up to 32k tokens), which a slow provider may take several
	 * minutes to emit. The async worker keeps the user's request from blocking,
	 * so a long wait is acceptable.
	 */
	public const GENERATION_TIMEOUT_SECONDS = 300.0;

	/**
	 * Wait budget for the shorter reviewer call (small JSON output).
	 *
	 * Sized as a safety net for the slower V4 Pro: with thinking correctly disabled
	 * (see injectThinkingDisabled) the review returns in ~15-25s, but Pro can still
	 * run heavier than Flash, so the budget stays above the 60s that previously
	 * produced cURL 28 timeouts.
	 */
	public const REVIEW_TIMEOUT_SECONDS = 120.0;

	public const CONNECT_TIMEOUT_SECONDS = 10.0;

	/**
	 * Silent re-calls when the reviewer JSON parse fails on a transient LLM response.
	 */
	public const MAX_SILENT_REVIEW_PARSE_RETRIES = 2;

	/**
	 * Providers whose SDK adapters map output schemas to the correct API shape.
	 *
	 * OpenAI-compatible providers (openai, deepseek) use AbstractOpenAiCompatibleTextGenerationModel,
	 * which sends json_schema in a format those APIs reject. DeepSeek only supports json_object.
	 */
	private const NATIVE_JSON_SCHEMA_PROVIDERS = array( 'anthropic', 'google' );

	/**
	 * Generate plain text from a system instruction and user message.
	 *
	 * @throws \Exception When the provider returns an error.
	 */
	public function generateText(
		string $systemInstruction,
		string $userMessage,
		float $temperature = 0.0,
		int $maxTokens = 8192
	): string {
		return $this->runWithDeepSeekCodegenOptimizations(
			function () use ($systemInstruction, $userMessage, $temperature, $maxTokens): string {
				$result = $this->buildPrompt($userMessage)
					->using_system_instruction($systemInstruction)
					->using_temperature($temperature)
					->using_max_tokens($maxTokens)
					->using_request_options($this->buildRequestOptions(self::GENERATION_TIMEOUT_SECONDS))
					->generate_text();

				return $this->unwrapTextResult($result, 'Plugin code generation failed.');
			}
		);
	}

	/**
	 * Generate structured JSON matching the given schema.
	 *
	 * Uses native output schemas on Anthropic and Google. Falls back to prompt JSON mode
	 * (json_object + schema in the system instruction) on OpenAI-compatible providers.
	 *
	 * @param array<string, mixed> $schema JSON Schema object.
	 * @return array<string, mixed>
	 *
	 * @throws \Exception When the provider returns an error or invalid JSON.
	 */
	public function generateJson(
		string $systemInstruction,
		string $userMessage,
		array $schema,
		float $temperature = 0.0,
		int $maxTokens = 4096
	): array {
		$provider = $this->resolveSelectedModel()['provider'];

		if ($this->providerUsesNativeJsonSchema($provider)) {
			return $this->generateJsonWithNativeSchema(
				$systemInstruction,
				$userMessage,
				$schema,
				$temperature,
				$maxTokens
			);
		}

		return $this->generateJsonWithPromptMode(
			$systemInstruction,
			$userMessage,
			$schema,
			$temperature,
			$maxTokens
		);
	}

	/**
	 * Generate structured JSON with silent parse retries for the reviewer pipeline.
	 *
	 * @param array<string, mixed> $schema JSON Schema object.
	 * @param callable(int): void|null $onRetry Invoked before each silent retry (1-based).
	 * @return array<string, mixed>
	 *
	 * @throws \Exception When retries are exhausted or a non-transient error occurs.
	 */
	public function generateJsonWithRetry(
		string $systemInstruction,
		string $userMessage,
		array $schema,
		?callable $onRetry = null,
		float $temperature = 0.0,
		int $maxTokens = 4096
	): array {
		$attempt     = 0;
		$maxAttempts = 1 + self::MAX_SILENT_REVIEW_PARSE_RETRIES;

		while (true) {
			++$attempt;

			try {
				return $this->generateJson(
					$systemInstruction,
					$userMessage,
					$schema,
					$temperature,
					$maxTokens
				);
			} catch (\Exception $e) {
				if (! self::isTransientReviewParseException($e) || $attempt >= $maxAttempts) {
					throw $e;
				}

				if ($onRetry !== null) {
					$onRetry($attempt);
				}
			}
		}
	}

	/**
	 * @param array<string, mixed> $schema
	 * @return array<string, mixed>
	 */
	private function generateJsonWithNativeSchema(
		string $systemInstruction,
		string $userMessage,
		array $schema,
		float $temperature,
		int $maxTokens
	): array {
		return $this->runWithDeepSeekCodegenOptimizations(
			function () use ($systemInstruction, $userMessage, $schema, $temperature, $maxTokens): array {
				$result = $this->buildPrompt($userMessage)
					->using_system_instruction($systemInstruction)
					->using_temperature($temperature)
					->using_max_tokens($maxTokens)
					->using_request_options($this->buildRequestOptions(self::REVIEW_TIMEOUT_SECONDS))
					->as_json_response($schema)
					->generate_text();

				$text = $this->unwrapTextResult($result, 'Plugin code review failed.');

				return StructuredJsonParser::parseObject($text);
			}
		);
	}

	/**
	 * Prompt JSON mode for providers that only accept response_format json_object.
	 *
	 * @param array<string, mixed> $schema
	 * @return array<string, mixed>
	 */
	private function generateJsonWithPromptMode(
		string $systemInstruction,
		string $userMessage,
		array $schema,
		float $temperature,
		int $maxTokens
	): array {
		return $this->runWithDeepSeekCodegenOptimizations(
			function () use ($systemInstruction, $userMessage, $schema, $temperature, $maxTokens): array {
				$result = $this->buildPrompt($userMessage)
					->using_system_instruction($this->buildPromptJsonSystemInstruction($systemInstruction, $schema))
					->using_temperature($temperature)
					->using_max_tokens($maxTokens)
					->using_request_options($this->buildRequestOptions(self::REVIEW_TIMEOUT_SECONDS))
					->as_json_response(null)
					->generate_text();

				$text = $this->unwrapTextResult($result, 'Plugin code review failed.');

				return StructuredJsonParser::parseObject($text);
			}
		);
	}

	/**
	 * @param array<string, mixed> $schema
	 */
	private function buildPromptJsonSystemInstruction( string $systemInstruction, array $schema ): string {
		$schemaJson = wp_json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
		if (! is_string($schemaJson)) {
			$schemaJson = '{}';
		}

		return $systemInstruction
			. "\n\nRespond in valid JSON only. No markdown, no explanation, no code fences.\n"
			. "Your JSON output must match this schema:\n"
			. $schemaJson;
	}

	/**
	 * @return \WP_AI_Client_Prompt_Builder
	 */
	private function buildPrompt( string $userMessage ) {
		$model = $this->resolveSelectedModel();

		return wp_ai_client_prompt($userMessage)
			->using_model_preference(array( $model['provider'], $model['model'] ));
	}

	/**
	 * Same model as LolaCore Settings → Model selector (`selected_model`).
	 *
	 * @return array{provider: string, model: string}
	 *
	 * @throws \Exception When no AI provider is configured.
	 */
	public function resolveSelectedModel(): array {
		try {
			return SelectedModelResolver::fromConfig(
				null,
				'No AI provider is configured for Plugin Builder.'
			);
		} catch (\RuntimeException $e) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new \Exception( esc_html( $e->getMessage() ), 0, $e );
		}
	}

	public function providerUsesNativeJsonSchema( string $provider ): bool {
		return in_array($provider, self::NATIVE_JSON_SCHEMA_PROVIDERS, true);
	}

	/**
	 * Output-token budget for a heavy generation call, clamped to the selected
	 * provider's safe ceiling. Single source of truth: ModelTokenBudget.
	 *
	 * @throws \Exception When no AI provider is configured.
	 */
	public function generationMaxTokens(): int {
		return ModelTokenBudget::forGeneration($this->resolveSelectedModel()['provider']);
	}

	/**
	 * @param mixed $result Text or WP_Error from the prompt builder.
	 */
	/**
	 * Wraps Plugin Builder AI calls with provider-specific request fixes.
	 *
	 * @template T
	 * @param callable(): T $callback
	 * @return T
	 */
	private function runWithDeepSeekCodegenOptimizations( callable $callback ) {
		$filter = $this->registerDeepSeekNonThinkingFilter();
		try {
			return $callback();
		} finally {
			if ($filter !== null) {
				remove_filter('http_request_args', $filter, 11);
			}
		}
	}

	/**
	 * DeepSeek V4 can emit plugin code in reasoning_content when thinking is on.
	 * Plugin Builder only reads main content — mirror WPAIClientAdapter chat fix.
	 *
	 * @return callable|null Filter callback when registered.
	 */
	private function registerDeepSeekNonThinkingFilter(): ?callable {
		$model = $this->resolveSelectedModel();
		if (($model['provider'] ?? '') !== 'deepseek') {
			return null;
		}
		$modelId = (string) ($model['model'] ?? '');
		if (! DeepSeekModelSupport::shouldDisableThinkingForCodegen($modelId)) {
			return null;
		}

		$filter = static function ( $args ) {
			return self::injectThinkingDisabled($args);
		};

		add_filter('http_request_args', $filter, 11, 1);

		return $filter;
	}

	/**
	 * Injects the top-level thinking=disabled toggle into an outgoing request body.
	 *
	 * DeepSeek exposes `thinking` as a TOP-LEVEL request field. `extra_body` is an
	 * OpenAI SDK construct that the SDK flattens into the top-level body before
	 * sending; it is NOT a field DeepSeek's HTTP API reads. Because LolaCore filters
	 * the raw HTTP body (no SDK in the loop), a nested `extra_body.thinking` is
	 * silently ignored, leaving V4 Pro reasoning until the review call hits cURL 28.
	 * The toggle must therefore sit at the top level: {"thinking":{"type":"disabled"}}.
	 *
	 * Decodes to OBJECTS, not associative arrays: json_decode(..., true) collapses
	 * every empty JSON object {} into [], and re-encoding then turns a schema's
	 * `properties: {}` into `properties: []`, which DeepSeek rejects ("[] is not of
	 * type object"). Object decoding preserves the {} vs [] distinction, so this
	 * injection cannot corrupt the rest of the body (response_format, schemas, etc.).
	 *
	 * @param array<string, mixed> $args WordPress HTTP request args.
	 * @return array<string, mixed> Modified args.
	 */
	public static function injectThinkingDisabled( array $args ): array {
		if (empty($args['body']) || ! is_string($args['body'])) {
			return $args;
		}
		$body = json_decode($args['body']);
		if (! $body instanceof \stdClass) {
			return $args;
		}
		$body->thinking = array( 'type' => 'disabled' );
		$args['body'] = wp_json_encode($body);
		return $args;
	}

	/**
	 * @param mixed $result Text or WP_Error from the prompt builder.
	 */
	public static function isTransientReviewParseException( \Exception $e ): bool {
		$message = $e->getMessage();

		return str_contains($message, 'invalid JSON')
			|| str_contains($message, 'empty JSON')
			|| str_contains($message, 'Plugin code review failed.');
	}

	/**
	 * @param mixed $result Text or WP_Error from the prompt builder.
	 */
	private function unwrapTextResult( $result, string $fallbackMessage ): string {
		if (is_wp_error($result)) {
			throw new \Exception(
				esc_html( ErrorMessages::formatProviderError( $result->get_error_message() ) )
			);
		}

		if (! is_string($result) || trim($result) === '') {
			throw new \Exception(
				esc_html(
					$fallbackMessage
					. __( ' Check that your model in LolaCore Settings returned plugin code in the main response (not only in a thinking block).', 'lolacore' )
				)
			);
		}

		return $result;
	}

	private function buildRequestOptions( float $timeoutSeconds ): RequestOptions {
		$options = new RequestOptions();
		$options->setTimeout($timeoutSeconds);
		$options->setConnectTimeout(self::CONNECT_TIMEOUT_SECONDS);
		return $options;
	}

	/**
	 * Collect lightweight site context for the generator prompt.
	 *
	 * @return array<string, mixed>
	 */
	public function buildDefaultSiteContext(): array {
		$context = array(
			'wp_version'  => get_bloginfo('version'),
			'php_version' => PHP_VERSION,
			'site_url'    => home_url('/'),
			'locale'      => get_locale(),
		);

		$theme = wp_get_theme();
		if ($theme->exists()) {
			$context['active_theme'] = array(
				'name'    => $theme->get('Name'),
				'version' => $theme->get('Version'),
				'slug'    => $theme->get_stylesheet(),
			);
		}

		if (function_exists('get_plugins')) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$active_slugs = array();
		if (function_exists('get_plugins')) {
			$all    = get_plugins();
			$active = (array) get_option('active_plugins', array());
			foreach ($active as $plugin_file) {
				if (! isset($all[ $plugin_file ])) {
					continue;
				}
				$slug = dirname($plugin_file);
				if ($slug === '.' || $slug === '') {
					$slug = basename($plugin_file, '.php');
				}
				$active_slugs[] = $slug;
			}
		}

		$context['active_plugins'] = array_values(array_unique($active_slugs));

		$woocommerce = $this->buildWooCommerceContext();
		if ($woocommerce !== array()) {
			$context['woocommerce'] = $woocommerce;
		}

		$selected = Settings::getConfig('selected_model', 'auto');
		if (is_string($selected) && $selected !== '') {
			$context['lolacore_model'] = $selected;
		}

		return $context;
	}

	/**
	 * WooCommerce-specific context for store-owner plugin generation (shipping thresholds, currency).
	 *
	 * @return array<string, mixed>
	 */
	private function buildWooCommerceContext(): array {
		if (! function_exists('is_plugin_active') || ! is_plugin_active('woocommerce/woocommerce.php')) {
			return array();
		}

		$context = array(
			'active'   => true,
			'currency' => function_exists('get_woocommerce_currency')
				? get_woocommerce_currency()
				: '',
		);

		$freeShippingMin = $this->readWooCommerceFreeShippingMinimum();
		if ($freeShippingMin !== null) {
			$context['free_shipping_min_amount'] = $freeShippingMin;
		}

		return $context;
	}

	/**
	 * Best-effort read of the lowest "free shipping" minimum from shipping zones.
	 */
	private function readWooCommerceFreeShippingMinimum(): ?float {
		if (! class_exists('WC_Shipping_Zones')) {
			return null;
		}

		$minimum = null;

		$zoneIds = array( 0 );
		$zones   = \WC_Shipping_Zones::get_zones();
		if (is_array($zones)) {
			foreach ($zones as $zone) {
				if (isset($zone['zone_id'])) {
					$zoneIds[] = (int) $zone['zone_id'];
				}
			}
		}

		foreach (array_unique($zoneIds) as $zoneId) {
			$zone = new \WC_Shipping_Zone($zoneId);
			foreach ($zone->get_shipping_methods(true) as $method) {
				if (! is_object($method) || $method->id !== 'free_shipping') {
					continue;
				}
				$requires = (string) $method->get_option('requires', '');
				if ($requires !== '' && $requires !== 'min_amount' && $requires !== 'either') {
					continue;
				}
				$amount = (float) $method->get_option('min_amount', 0);
				if ($amount <= 0) {
					continue;
				}
				if ($minimum === null || $amount < $minimum) {
					$minimum = $amount;
				}
			}
		}

		return $minimum;
	}
}
