<?php

namespace WPSN\Modules\Media;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Modules\AI\AICallRepository;
use WPSN\Modules\AI\AICreditsRepository;
use WPSN\Modules\AI\AISecretsRepository;
use WPSN\Modules\AI\AIUsageRepository;
use WPSN\Modules\AI\OpenAIClient;
use WPSN\Modules\AI\PromptLibrary;
use WPSN\Modules\AI\SaaSAIProxyClient;
use WPSN\Modules\AI\SaaSAICreditsRepository;
use WPSN\Modules\SaaSSync\SaaSSyncService;
use WPSN\Modules\AI\Redactor;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;

defined('ABSPATH') || exit;

final class MediaJobs
{
    public const META_SUGGESTIONS = '_wpsn_media_suggestions';
    public const META_STATUS      = '_wpsn_media_suggestions_status'; // draft|applied
    public const META_UPDATED     = '_wpsn_media_suggestions_updated'; // timestamp
    public const META_SOURCE      = '_wpsn_media_suggestions_source'; // local|openai
    public const META_UNDO        = '_wpsn_media_undo_snapshot'; // JSON

    private Settings $settings;
    private Logger $logger;

    private AISecretsRepository $secrets;
    private AIUsageRepository $usage;
    private AICallRepository $calls;
    private AICreditsRepository $credits;
    private LocalAltSuggestionEngine $local;

    public function __construct(Settings $settings, Logger $logger)
    {
        $this->settings = $settings;
        $this->logger   = $logger;

        $this->secrets = new AISecretsRepository();
        $this->usage   = new AIUsageRepository();
        $this->calls   = new AICallRepository();
        $this->credits = new AICreditsRepository($settings);
        $this->local   = new LocalAltSuggestionEngine();
    }

    /**
     * Generate an alt text draft for an image attachment.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleGenerateAlt($unused, array $payload, array $jobRow): array
    {
        $attachmentId = isset($payload['attachment_id']) ? (int) $payload['attachment_id'] : 0;
        if ($attachmentId <= 0) {
            return ['status' => 'done'];
        }

        $post = get_post($attachmentId);
        if (!$post || !isset($post->post_type) || (string) $post->post_type !== 'attachment') {
            return ['status' => 'done'];
        }

        $initiatorUserId = isset($payload['user_id']) ? (int) $payload['user_id'] : 0;
        if ($initiatorUserId <= 0) {
            $initiatorUserId = 0;
        }

        $mime = (string) get_post_mime_type($attachmentId);
        if ($mime === '' || strpos($mime, 'image/') !== 0) {
            return ['status' => 'done'];
        }

        $source = isset($payload['source']) ? sanitize_key((string) $payload['source']) : 'local';
        if (!in_array($source, ['local', 'openai'], true)) {
            $source = 'local';
        }

        $onlyIfEmpty = !empty($payload['only_if_empty']);
        $overwriteSuggestions = !empty($payload['overwrite_suggestions']);

        if (!$overwriteSuggestions) {
            $existing = (string) get_post_meta($attachmentId, self::META_STATUS, true);
            if ($existing !== '') {
                return ['status' => 'done'];
            }
        }

        $currentAlt = (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true);
        if ($onlyIfEmpty && trim($currentAlt) !== '') {
            return ['status' => 'done'];
        }

        $maxLen = isset($payload['max_length']) ? (int) $payload['max_length'] : 125;
        $maxLen = max(20, min(300, $maxLen));

        $suggestion = [
            'version' => 1,
            'status' => 'draft',
            'source' => $source,
            'generated_at' => gmdate('c'),
            'fields' => [],
        ];

        if ($source === 'local') {
            // Respect the same toggle as other local suggestions.
            if (!(bool) $this->settings->get('ai.local.enabled', true)) {
                return ['status' => 'failed', 'error' => __('Local suggestions are disabled in Settings.', 'wp-seo-next')];
            }

            $alt = $this->local->suggest($attachmentId, $maxLen);
            $alt = $this->sanitizeAlt($alt, $maxLen);

            if ($alt === '') {
                return ['status' => 'done'];
            }

            $suggestion['fields']['alt_text'] = [
                'text' => $alt,
                'provider' => 'local',
            ];

            $this->storeSuggestion($attachmentId, $suggestion);
            return ['status' => 'done'];
        }

        // External AI generation (OpenAI or SaaS proxy)
        if ((bool) $this->settings->get('privacy.local_only', false)) {
            return ['status' => 'failed', 'error' => __('External services are disabled by Local-only mode.', 'wp-seo-next')];
        }

        if (!(bool) $this->settings->get('ai.external.enabled', false)) {
            return ['status' => 'failed', 'error' => __('External AI is disabled in Settings.', 'wp-seo-next')];
        }

        $provider = sanitize_key((string) $this->settings->get('ai.external.provider', 'openai'));
        if (!in_array($provider, ['openai', 'saas_proxy'], true)) {
            return ['status' => 'failed', 'error' => __('Unsupported AI provider.', 'wp-seo-next')];
        }

        $apiKey = '';
        $saas = null;

        if ($provider === 'openai') {
            $apiKey = $this->secrets->getOpenAIKey();
            if ($apiKey === '') {
                return ['status' => 'failed', 'error' => __('OpenAI API key is not configured.', 'wp-seo-next')];
            }
        } elseif ($provider === 'saas_proxy') {
            if (!$this->settings->moduleEnabled('saas_sync', false) || !(bool) $this->settings->get('saas_sync.enabled', false)) {
                return ['status' => 'failed', 'error' => __('SaaS Sync module is disabled.', 'wp-seo-next')];
            }

            $saas = new SaaSSyncService($this->settings, $this->logger);
            if (!$saas->isActive() || !$saas->isTrusted()) {
                return ['status' => 'failed', 'error' => __('SaaS policy is not active/trusted yet. Open “SEO SaaS Sync” and run sync.', 'wp-seo-next')];
            }
        }

        $model = (string) $this->settings->get('ai.external.model', 'gpt-4.1-mini');
        $maxOutput = (int) $this->settings->get('ai.external.max_output_tokens', 180);
        $temperature = (float) $this->settings->get('ai.external.temperature', 0.7);
        $org = (string) $this->settings->get('ai.external.openai_org', '');
        $project = (string) $this->settings->get('ai.external.openai_project', '');

        $useVision = !empty($payload['use_vision']);
        $visionDetail = isset($payload['vision_detail']) ? sanitize_key((string) $payload['vision_detail']) : 'low';
        if (!in_array($visionDetail, ['low', 'high', 'auto'], true)) {
            $visionDetail = 'low';
        }

        if ($provider === 'openai') {
            $client = new OpenAIClient($apiKey, $org, $project, $this->logger);
        } else {
            $client = new SaaSAIProxyClient($saas, $this->logger, $this->settings);
        }

        $siteName = (string) get_bloginfo('name');
        $locale = function_exists('determine_locale') ? (string) determine_locale() : (string) get_locale();

        $attachmentTitle = (string) get_the_title($attachmentId);
        $caption = isset($post->post_excerpt) ? (string) $post->post_excerpt : '';
        $filename = (string) get_post_meta($attachmentId, '_wp_attached_file', true);
        if ($filename !== '') {
            $filename = basename((string) $filename);
        } else {
            $filename = (string) wp_basename((string) get_attached_file($attachmentId));
        }

        $parentTitle = '';
        $parentExcerpt = '';
        if (!empty($post->post_parent)) {
            $parentId = (int) $post->post_parent;
            if ($parentId > 0) {
                $parentTitle = (string) get_the_title($parentId);
                $parentPost = get_post($parentId);
                if ($parentPost) {
                    $pe = (string) $parentPost->post_excerpt;
                    if ($pe === '') {
                        $pe = (string) wp_strip_all_tags(get_the_excerpt($parentId));
                    }
                    $parentExcerpt = $this->toPlainText($pe);
                }
            }
        }

        $promptKey = PromptLibrary::KEY_MEDIA_ALT;
        $prompts = new PromptLibrary($this->settings);
        $built = $prompts->build($promptKey, [
            '{sitename}' => $siteName,
            '{language}' => $locale,
            '{max_length}' => (string) $maxLen,
            '{attachment_title}' => $attachmentTitle,
            '{caption}' => $this->toPlainText($caption),
            '{filename}' => (string) $filename,
            '{parent_title}' => $parentTitle,
            '{parent_excerpt}' => $parentExcerpt,
            '{current_alt}' => $this->toPlainText($currentAlt),
        ]);

        $promptVersion = (int) ($built['version'] ?? 1);
        $promptHash = (string) ($built['hash'] ?? '');
        $prompt = (string) ($built['prompt'] ?? '');

        if ((bool) $this->settings->get('ai.privacy.redact_pii', true)) {
            $prompt = Redactor::redactPersonalData($prompt);
        }

        // Rate limit backoff input (used by AJAX chunker).
        $backoffSeconds = isset($payload['backoff_seconds']) ? (int) $payload['backoff_seconds'] : 10;
        $backoffSeconds = max(1, min(120, $backoffSeconds));

        // Enforce daily limits (best-effort).
        $limitError = $this->checkDailyLimits($prompt, $maxOutput);
        if ($limitError !== '') {
            $this->calls->insert([
                'provider' => $provider,
                'model' => $model,
                'purpose' => 'media_alt',
                'post_id' => $attachmentId,
                'prompt_key' => $promptKey,
                'prompt_version' => $promptVersion,
                'prompt_hash' => $promptHash,
                'success' => false,
                'error_message' => $limitError,
            ]);
            return ['status' => 'failed', 'error' => $limitError];
        }

        // Enforce credits (optional).
        $creditsError = $this->checkCredits($prompt, $maxOutput);
        if ($creditsError !== '') {
            $this->calls->insert([
                'provider' => $provider,
                'model' => $model,
                'purpose' => 'media_alt',
                'post_id' => $attachmentId,
                'prompt_key' => $promptKey,
                'prompt_version' => $promptVersion,
                'prompt_hash' => $promptHash,
                'success' => false,
                'error_message' => $creditsError,
            ]);
            return ['status' => 'failed', 'error' => $creditsError];
        }

        $input = $prompt;

        if ($useVision) {
            $imageUrl = (string) wp_get_attachment_url($attachmentId);
            if ($imageUrl !== '') {
                $input = [
                    [
                        'role' => 'user',
                        'content' => [
                            ['type' => 'input_text', 'text' => $prompt],
                            ['type' => 'input_image', 'image_url' => $imageUrl, 'detail' => $visionDetail],
                        ],
                    ],
                ];
            }
        }

        if ($client instanceof SaaSAIProxyClient) {
            $resp = $client->generate($model, $input, $maxOutput, $temperature, 'media_alt', [
                'attachment_id' => $attachmentId,
                'prompt_version' => $promptVersion,
                'prompt_key' => $promptKey,
                'prompt_hash' => $promptHash,
                'vision' => $useVision ? 1 : 0,
                'vision_detail' => $visionDetail,
            ]);
        } else {
            $resp = $client->generate($model, $input, $maxOutput, $temperature);
        }

        $tokensIn = (int) ($resp['usage']['input'] ?? 0);
        $tokensOut = (int) ($resp['usage']['output'] ?? 0);
        $tokensTotal = (int) ($resp['usage']['total'] ?? 0);

        $cost = isset($resp['cost_usd'])
            ? (float) $resp['cost_usd']
            : $this->estimateCost(
                $tokensIn,
                $tokensOut,
                (float) $this->settings->get('ai.external.price_input_per_1m', 0.0),
                (float) $this->settings->get('ai.external.price_output_per_1m', 0.0)
            );

        $storeResponses = (bool) $this->settings->get('ai.external.store_responses', false);

        if (!$resp['ok']) {
            $err = isset($resp['error']) ? (string) $resp['error'] : __('AI error.', 'wp-seo-next');
            $http = isset($resp['http_status']) ? (int) $resp['http_status'] : 0;
            $retryAfter = isset($resp['retry_after']) ? (int) $resp['retry_after'] : 0;

            $this->calls->insert([
                'provider' => $provider,
                'model' => $model,
                'purpose' => 'media_alt',
                'post_id' => $attachmentId,
                'prompt_key' => $promptKey,
                'prompt_version' => $promptVersion,
                'prompt_hash' => $promptHash,
                'tokens_in' => $tokensIn,
                'tokens_out' => $tokensOut,
                'tokens_total' => $tokensTotal,
                'cost_usd' => $cost,
                'latency_ms' => (int) ($resp['latency_ms'] ?? 0),
                'success' => false,
                'error_message' => $err,
                'response_text' => $storeResponses ? (string) ($resp['text'] ?? '') : '',
            ]);

            // Soft retry on rate limit.
            if ($http === 429) {
                $retryAfter = $retryAfter > 0 ? $retryAfter : $backoffSeconds;
                return [
                    'status' => 'retry',
                    'rate_limited' => true,
                    'retry_after' => $retryAfter,
                    'error' => $err,
                ];
            }

            return ['status' => 'failed', 'error' => $err];
        }

        // Debit credits (optional local wallet). Only meaningful for direct OpenAI mode.
        if ($provider === 'openai' && $this->credits->isEnabled()) {
            $creditsUsed = $this->credits->actualRequiredCredits($tokensTotal, $cost);
            if ($creditsUsed > 0.0) {
                $ok = $this->credits->spend($creditsUsed, [
                    'user_id' => $initiatorUserId,
                    'mode' => $this->credits->mode(),
                    'provider' => $provider,
                    'model' => $model,
                    'purpose' => 'media_alt',
                    'object_type' => 'attachment',
                    'object_id' => $attachmentId,
                    'tokens_total' => $tokensTotal,
                    'cost_usd' => $cost,
                    'note' => 'media_alt',
                    'meta' => [
                        'job_id' => isset($jobRow['id']) ? (int) $jobRow['id'] : 0,
                    ],
                ]);

                if (!$ok) {
                    return ['status' => 'failed', 'error' => __('AI credits depleted during processing.', 'wp-seo-next')];
                }
            }
        }

        // Track usage.
        $this->usage->addUsage(1, $tokensIn, $tokensOut, $tokensTotal, $cost);

        // Update cached SaaS credits (best-effort).
        if ($provider === 'saas_proxy' && !empty($resp['credits']) && is_array($resp['credits'])) {
            try {
                (new SaaSAICreditsRepository())->updateFromCreditsArray($resp['credits']);
            } catch (\Throwable $e) {
                // ignore
            }
        }

        $this->calls->insert([
            'provider' => $provider,
            'model' => $model,
            'purpose' => 'media_alt',
            'post_id' => $attachmentId,
            'prompt_key' => $promptKey,
            'prompt_version' => $promptVersion,
            'prompt_hash' => $promptHash,
            'tokens_in' => $tokensIn,
            'tokens_out' => $tokensOut,
            'tokens_total' => $tokensTotal,
            'cost_usd' => $cost,
            'latency_ms' => (int) ($resp['latency_ms'] ?? 0),
            'success' => true,
            'error_message' => '',
            'response_text' => $storeResponses ? (string) ($resp['text'] ?? '') : '',
        ]);

        $alt = $this->sanitizeAlt((string) ($resp['text'] ?? ''), $maxLen);
        if ($alt === '') {
            return ['status' => 'failed', 'error' => __('Empty response from AI provider.', 'wp-seo-next')];
        }

        $suggestion['fields']['alt_text'] = [
            'text' => $alt,
            'provider' => $provider,
            'model' => $model,
            'prompt_version' => $promptVersion,
            'prompt_key' => $promptKey,
            'prompt_hash' => $promptHash,
            'usage' => [
                'input' => $tokensIn,
                'output' => $tokensOut,
                'total' => $tokensTotal,
            ],
            'cost_usd' => $cost,
            'vision' => $useVision ? 1 : 0,
        ];

        $this->storeSuggestion($attachmentId, $suggestion);

        return ['status' => 'done'];
    }

    /**
     * Apply a generated alt draft.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleApplyAlt($unused, array $payload, array $jobRow): array
    {
        $attachmentId = isset($payload['attachment_id']) ? (int) $payload['attachment_id'] : 0;
        if ($attachmentId <= 0) {
            return ['status' => 'done'];
        }

        $raw = (string) get_post_meta($attachmentId, self::META_SUGGESTIONS, true);
        if ($raw === '') {
            return ['status' => 'done'];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['status' => 'done'];
        }

        $status = isset($data['status']) ? (string) $data['status'] : '';
        if ($status !== 'draft') {
            return ['status' => 'done'];
        }

        $fields = isset($data['fields']) && is_array($data['fields']) ? $data['fields'] : [];
        $alt = isset($fields['alt_text']['text']) ? trim((string) $fields['alt_text']['text']) : '';

        if ($alt === '') {
            return ['status' => 'done'];
        }

        // Default: apply-only-changed ON.
        $applyOnlyChanged = !array_key_exists('apply_only_changed', $payload) || (bool) $payload['apply_only_changed'];

        $currentAlt = (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true);
        $willUpdate = (!$applyOnlyChanged || trim($alt) !== trim($currentAlt));

        $didUpdate = false;

        if ($willUpdate) {
            $this->storeUndoSnapshot($attachmentId, $currentAlt);

            // Unified revisions log (Block C).
            if ($this->settings->moduleEnabled('revisions', true)) {
                try {
                    RevisionsInstaller::maybeInstall();
                    $revRepo = new RevisionsRepository($this->logger);

                    $p = get_post($attachmentId);
                    $revRepo->insert([
                        'object_type' => 'post',
                        'object_id' => $attachmentId,
                        'object_subtype' => $p ? (string) $p->post_type : 'attachment',
                        'action' => 'apply',
                        'source' => 'media',
                        'actor_user_id' => (int) get_current_user_id(),
                        'before' => ['meta' => ['_wp_attachment_image_alt' => $currentAlt]],
                        'after'  => ['meta' => ['_wp_attachment_image_alt' => $alt]],
                        'meta' => [
                            'apply_only_changed' => $applyOnlyChanged,
                        ],
                    ]);
                } catch (\Throwable $e) {
                    // Never block apply if revisions logging fails.
                }
            }


            if ($alt === '') {
                delete_post_meta($attachmentId, '_wp_attachment_image_alt');
            } else {
                update_post_meta($attachmentId, '_wp_attachment_image_alt', $alt);
            }

            $didUpdate = true;
        }

        // Mark applied.
        $data['status'] = 'applied';
        $data['applied_at'] = gmdate('c');

        update_post_meta($attachmentId, self::META_SUGGESTIONS, wp_json_encode($data));
        update_post_meta($attachmentId, self::META_STATUS, 'applied');
        update_post_meta($attachmentId, self::META_UPDATED, time());

        if ($didUpdate) {
            /**
             * Fires when attachment alt text has been updated by WP SEO Next.
             */
            do_action('seo/media/alt_updated', $attachmentId);
        }

        return ['status' => 'done'];
    }

    /**
     * @param array<string,mixed> $suggestion
     */
    private function storeSuggestion(int $attachmentId, array $suggestion): void
    {
        update_post_meta($attachmentId, self::META_SUGGESTIONS, wp_json_encode($suggestion));
        update_post_meta($attachmentId, self::META_STATUS, 'draft');
        update_post_meta($attachmentId, self::META_SOURCE, isset($suggestion['source']) ? (string) $suggestion['source'] : '');
        update_post_meta($attachmentId, self::META_UPDATED, time());
    }

    private function sanitizeAlt(string $text, int $maxLen): string
    {
        $text = $this->toPlainText($text);

        // Remove wrapping quotes.
        $text = preg_replace('/^\s*["\'“”‘’]+/u', '', (string) $text);
        $text = preg_replace('/["\'“”‘’]+\s*$/u', '', (string) $text);

        $text = trim((string) $text);
        if ($text === '') {
            return '';
        }

        // Keep it single line.
        $text = preg_replace('/\s+/u', ' ', (string) $text) ?? $text;
        $text = trim((string) $text);

        // Enforce length.
        $text = $this->truncateChars($text, $maxLen);

        return trim((string) $text);
    }

    private function defaultAltPromptTemplate(): string
    {
        return "You are an SEO assistant. Write concise image ALT text for accessibility and SEO.\n\nRules:\n- Language: {language}\n- Max length: {max_length} characters\n- Describe what is visible in the image (no keyword stuffing)\n- Do NOT use quotes\n- Return ONLY the alt text\n\nContext:\nSite: {sitename}\nAttachment title: {attachment_title}\nCaption: {caption}\nFilename: {filename}\nParent post title: {parent_title}\nParent excerpt: {parent_excerpt}\nCurrent alt: {current_alt}";
    }

    /**
     * @param array<string,string> $vars
     */
    private function renderPrompt(string $template, array $vars): string
    {
        return (string) strtr($template, $vars);
    }

    private function storeUndoSnapshot(int $attachmentId, string $currentAlt): void
    {
        $history = [];

        $existingRaw = (string) get_post_meta($attachmentId, self::META_UNDO, true);
        if ($existingRaw !== '') {
            $decoded = json_decode($existingRaw, true);
            if (is_array($decoded)) {
                if (isset($decoded['history']) && is_array($decoded['history'])) {
                    foreach ($decoded['history'] as $entry) {
                        if (is_array($entry)) {
                            $history[] = [
                                'saved_at' => isset($entry['saved_at']) ? (string) $entry['saved_at'] : '',
                                'alt' => isset($entry['alt']) ? (string) $entry['alt'] : '',
                            ];
                        }
                    }
                } elseif (isset($decoded['alt'])) {
                    // Backward compatibility.
                    $history[] = [
                        'saved_at' => isset($decoded['saved_at']) ? (string) $decoded['saved_at'] : '',
                        'alt' => (string) ($decoded['alt'] ?? ''),
                    ];
                }
            }
        }

        $isDuplicate = false;
        if (!empty($history)) {
            $last = $history[count($history) - 1];
            if (is_array($last) && isset($last['alt']) && (string) $last['alt'] === (string) $currentAlt) {
                $isDuplicate = true;
            }
        }

        if (!$isDuplicate) {
            $history[] = [
                'saved_at' => gmdate('c'),
                'alt' => (string) $currentAlt,
            ];
        }

        $maxHistory = 20;
        if (count($history) > $maxHistory) {
            $history = array_slice($history, -$maxHistory);
        }

        update_post_meta($attachmentId, self::META_UNDO, wp_json_encode([
            'version' => 2,
            'history' => $history,
        ]));
    }

    private function toPlainText(string $html): string
    {
        $text = wp_strip_all_tags($html);
        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
        $text = preg_replace('/\s+/', ' ', (string) $text);
        return trim((string) $text);
    }

    private function truncateChars(string $text, int $maxChars): string
    {
        if ($maxChars <= 0) {
            return '';
        }
        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
            if ((int) mb_strlen($text) > $maxChars) {
                return (string) mb_substr($text, 0, $maxChars);
            }
            return $text;
        }
        if (strlen($text) > $maxChars) {
            return substr($text, 0, $maxChars);
        }
        return $text;
    }

    private function estimateTokens(string $text): int
    {
        $len = function_exists('mb_strlen') ? (int) mb_strlen($text) : (int) strlen($text);
        return (int) ceil($len / 4);
    }

    private function estimateCost(int $tokensIn, int $tokensOut, float $priceInPer1M, float $priceOutPer1M): float
    {
        $cost = 0.0;
        if ($priceInPer1M > 0) {
            $cost += ($tokensIn / 1000000.0) * $priceInPer1M;
        }
        if ($priceOutPer1M > 0) {
            $cost += ($tokensOut / 1000000.0) * $priceOutPer1M;
        }
        return $cost;
    }

    private function checkDailyLimits(string $prompt, int $maxOutputTokens): string
    {
        $usage = $this->usage->getToday();

        $reqLimit = (int) $this->settings->get('ai.external.daily_request_limit', 0);
        $tokLimit = (int) $this->settings->get('ai.external.daily_token_limit', 0);
        $budgetUsd = (float) $this->settings->get('ai.external.daily_budget_usd', 0.0);

        $priceIn = (float) $this->settings->get('ai.external.price_input_per_1m', 0.0);
        $priceOut = (float) $this->settings->get('ai.external.price_output_per_1m', 0.0);

        $reqs = (int) ($usage['requests'] ?? 0);
        $toks = (int) ($usage['tokens_total'] ?? 0);
        $cost = (float) ($usage['cost_usd'] ?? 0.0);

        $estIn = $this->estimateTokens($prompt);
        $estOut = max(16, $maxOutputTokens);
        $estCost = $this->estimateCost($estIn, $estOut, $priceIn, $priceOut);

        if ($reqLimit > 0 && ($reqs + 1) > $reqLimit) {
            return __('Daily AI request limit reached.', 'wp-seo-next');
        }

        if ($tokLimit > 0 && ($toks + $estIn + $estOut) > $tokLimit) {
            return __('Daily AI token limit reached.', 'wp-seo-next');
        }

        if ($budgetUsd > 0.0 && ($cost + $estCost) > $budgetUsd) {
            return __('Daily AI budget limit reached.', 'wp-seo-next');
        }

        return '';
    }

    private function checkCredits(string $prompt, int $maxOutputTokens): string
    {
        $provider = sanitize_key((string) $this->settings->get('ai.external.provider', 'openai'));
        if ($provider !== 'openai') {
            // When using SaaS proxy, billing is expected on the SaaS side.
            return '';
        }

        if (!$this->credits->isEnabled()) {
            return '';
        }

        $priceIn = (float) $this->settings->get('ai.external.price_input_per_1m', 0.0);
        $priceOut = (float) $this->settings->get('ai.external.price_output_per_1m', 0.0);

        $estIn = $this->estimateTokens($prompt);
        $estOut = max(16, $maxOutputTokens);

        // Safety margin: estimated tokens can be low.
        $margin = max(50, (int) ceil(($estIn + $estOut) * 0.10));
        $estIn = (int) ceil($estIn * 1.10);
        $estOut = $estOut + $margin;

        $estCost = $this->estimateCost($estIn, $estOut, $priceIn, $priceOut);
        $needed = $this->credits->estimateRequiredCredits($estIn, $estOut, $estCost);

        if ($needed <= 0.0) {
            return '';
        }

        $balance = $this->credits->getBalance();
        if ($balance + 0.000001 < $needed) {
            return sprintf(
                /* translators: 1: needed credits, 2: balance */
                __('AI credits depleted. Need ~%1$s credits, balance %2$s.', 'wp-seo-next'),
                (string) $needed,
                (string) $balance
            );
        }

        return '';
    }
}
