<?php

namespace WPSN\Modules\Bulk;

use WPSN\Core\Logger;
use WPSN\Core\Queue\QueueInterface;
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\LocalSuggestionEngine;
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\Bulk\BulkSessionReportRepository;
use WPSN\Modules\Audit\AuditJobs;
use WPSN\Modules\Audit\AuditMetaSignal;
use WPSN\SEO\MetaKeys;
use WPSN\SEO\ContentExtractor;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;

defined('ABSPATH') || exit;

final class BulkJobs
{
    public const META_SUGGESTIONS = '_wpsn_suggestions';
    public const META_STATUS = '_wpsn_suggestions_status'; // draft|applied
    public const META_UPDATED = '_wpsn_suggestions_updated'; // timestamp
    public const META_SOURCE = '_wpsn_suggestions_source'; // local|openai
    public const META_UNDO = '_wpsn_undo_snapshot'; // JSON

    private Settings $settings;
    private Logger $logger;
    private QueueInterface $queue;

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

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

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

    public static function shouldGenerateField(string $currentValue, bool $onlyIfEmpty): bool
    {
        if (!$onlyIfEmpty) {
            return true;
        }

        if (trim($currentValue) === '') {
            return true;
        }

        return !empty(AuditMetaSignal::analyzeRenderedMeta($currentValue)['blank']);
    }

    public static function repairCorruptedUnicodeEscapes(string $text): string
    {
        $text = (string) $text;
        if ($text === '' || strpos($text, 'u') === false) {
            return $text;
        }

        $repaired = preg_replace_callback('/(?<!\\\\)u([0-9a-fA-F]{4})/', static function (array $matches): string {
            $decoded = json_decode('"\u' . strtoupper((string) ($matches[1] ?? '')) . '"');
            return is_string($decoded) ? $decoded : (string) ($matches[0] ?? '');
        }, $text);
        return is_string($repaired) ? $repaired : $text;
    }

    /**
     * @param array<string,mixed> $data
     */
    private function encodeJsonMeta(array $data): string
    {
        $json = wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($json) || $json === '') {
            $json = '{}';
        }

        if (function_exists('wp_slash')) {
            return (string) wp_slash($json);
        }

        return addslashes($json);
    }

    /**
     * Queue handler: bulk_generate
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleGenerate($unused, array $payload, array $jobRow): array
    {
        $objectType = isset($payload['object_type']) ? sanitize_key((string) $payload['object_type']) : 'post';
        $objectId = isset($payload['object_id']) ? (int) $payload['object_id'] : 0;
        $objectSubtype = isset($payload['object_subtype']) ? sanitize_key((string) $payload['object_subtype']) : '';
        $bulkSessionId = isset($payload['bulk_session_id']) ? sanitize_key((string) $payload['bulk_session_id']) : '';

        if ($objectId <= 0 && isset($payload['post_id'])) {
            $objectType = 'post';
            $objectId = (int) $payload['post_id'];
        }

        if (!in_array($objectType, ['post', 'term'], true) || $objectId <= 0) {
            return ['status' => 'done'];
        }

        $reportRepo = $bulkSessionId !== '' ? new BulkSessionReportRepository() : null;
        if ($reportRepo instanceof BulkSessionReportRepository) {
            $reportRepo->touch($bulkSessionId);
        }

        $post = null;
        $term = null;
        if ($objectType === 'post') {
            $post = get_post($objectId);
            if (!$post) {
                return ['status' => 'done'];
            }
            if ($objectSubtype === '') {
                $objectSubtype = (string) $post->post_type;
            }
        } else {
            $term = get_term($objectId);
            if (!$term || is_wp_error($term)) {
                return ['status' => 'done'];
            }
            if ($objectSubtype === '') {
                $objectSubtype = (string) $term->taxonomy;
            }
        }

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

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

        $fields = isset($payload['fields']) && is_array($payload['fields']) ? array_values($payload['fields']) : ['title', 'description'];
        $fields = array_values(array_intersect(['title', 'description'], array_map('sanitize_key', $fields)));

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

        if (!$overwriteSuggestions) {
            $existingStatus = $this->resolveStoredSuggestionStatus($objectType, $objectId);
            if ($existingStatus === 'draft') {
                if ($reportRepo instanceof BulkSessionReportRepository) {
                    $reportRepo->recordGenerate($bulkSessionId, [
                        'object_type' => $objectType,
                        'object_id' => $objectId,
                        'object_subtype' => $objectSubtype,
                        'generate_status' => 'skipped_existing_suggestion',
                        'generate_note' => __('Skipped because a suggestion draft already exists for this object.', 'wp-seo-next'),
                    ]);
                }
                return ['status' => 'done'];
            }
        }

        $currentTitle = $this->readObjectMeta($objectType, $objectId, MetaKeys::TITLE);
        $currentDesc  = $this->readObjectMeta($objectType, $objectId, MetaKeys::DESCRIPTION);
        $issueCodes = $this->normalizeIssueCodes($payload);

        $todo = [];
        if (in_array('title', $fields, true) && self::shouldGenerateField($currentTitle, $onlyIfEmpty)) {
            $todo[] = 'title';
        }
        if (in_array('description', $fields, true) && self::shouldGenerateField($currentDesc, $onlyIfEmpty)) {
            $todo[] = 'description';
        }

        if (empty($todo)) {
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordGenerate($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'generate_status' => 'skipped_no_fields_needed',
                    'generate_note' => __('Skipped because the current SEO title/description did not need regeneration for this action.', 'wp-seo-next'),
                ]);
            }
            return ['status' => 'done'];
        }

        $objectTitle = '';
        $plainExcerpt = '';
        $plainContent = '';
        $maxContent = (int) $this->settings->get('ai.local.max_content_chars', 12000);

        if ($objectType === 'post' && $post) {
            $objectTitle = (string) get_the_title($objectId);
            $extractor = new ContentExtractor();
            $plainExcerpt = $extractor->getPostExcerptText($objectId, 28);
            $plainContent = $extractor->getPostText($objectId, ['context' => 'bulk', 'max_chars' => $maxContent]);
        } else {
            $objectTitle = $term ? trim((string) $term->name) : '';
            $termDescription = '';
            if ($term) {
                $termDescription = (string) get_term_field('description', $objectId, $objectSubtype);
                if (is_wp_error($termDescription)) {
                    $termDescription = '';
                }
            }
            $plainExcerpt = $this->toPlainText($termDescription);
            $plainContent = $plainExcerpt;
        }

        $titleMax = (int) $this->settings->get('ai.local.title_max_length', 60);
        $descMax  = (int) $this->settings->get('ai.local.description_max_length', 155);

        $suggestion = [
            'version' => 1,
            'status' => 'draft',
            'source' => $source,
            'object_type' => $objectType,
            'object_id' => $objectId,
            'object_subtype' => $objectSubtype,
            'generated_at' => gmdate('c'),
            'bulk_session_id' => $bulkSessionId,
            'fields' => [],
        ];

        if ($source === 'local') {
            if (!(bool) $this->settings->get('ai.local.enabled', true)) {
                if ($reportRepo instanceof BulkSessionReportRepository) {
                    $reportRepo->recordGenerate($bulkSessionId, [
                        'object_type' => $objectType,
                        'object_id' => $objectId,
                        'object_subtype' => $objectSubtype,
                        'generate_status' => 'failed_local_disabled',
                        'generate_note' => __('Local suggestions are disabled in Settings.', 'wp-seo-next'),
                    ]);
                }
                return ['status' => 'failed', 'error' => __('Local suggestions are disabled in Settings.', 'wp-seo-next')];
            }

            $s = $this->local->suggest([
                'post_id' => $objectType === 'post' ? $objectId : 0,
                'post_title' => $objectTitle,
                'excerpt' => $plainExcerpt,
                'content' => $plainContent,
                'title_max_length' => $titleMax,
                'description_max_length' => $descMax,
                'keywords_limit' => (int) $this->settings->get('ai.local.keywords_limit', 12),
            ]);

            $s = $this->refineLocalSuggestions($s, [
                'object_type' => $objectType,
                'object_id' => $objectId,
                'object_subtype' => $objectSubtype,
                'object_title' => $objectTitle,
                'current_title' => $currentTitle,
                'current_description' => $currentDesc,
                'issue_codes' => $issueCodes,
                'title_max_length' => $titleMax,
                'description_max_length' => $descMax,
            ]);

            if (in_array('title', $todo, true)) {
                $suggestion['fields']['title'] = [
                    'text' => (string) ($s['title'] ?? ''),
                    'provider' => 'local',
                ];
            }
            if (in_array('description', $todo, true)) {
                $suggestion['fields']['description'] = [
                    'text' => (string) ($s['description'] ?? ''),
                    'provider' => 'local',
                ];
            }

            $suggestion['keywords'] = isset($s['keywords']) && is_array($s['keywords']) ? array_values($s['keywords']) : [];

            $this->storeSuggestion($objectType, $objectId, $suggestion);

            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordGenerate($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'generate_status' => 'generated',
                    'suggestion_source' => 'local',
                    'suggested_title' => isset($suggestion['fields']['title']['text']) ? (string) $suggestion['fields']['title']['text'] : '',
                    'suggested_description' => isset($suggestion['fields']['description']['text']) ? (string) $suggestion['fields']['description']['text'] : '',
                ]);
            }

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

        // External AI (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 = '';
        $client = null;
        $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')];
            }
        }

        if ($objectType === 'post' && $post && (bool) $this->settings->get('ai.privacy.no_drafts', true) && $post->post_status !== 'publish') {
            return ['status' => 'failed', 'error' => __('Drafts are blocked by Privacy settings (publish the post or disable the option).', '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', '');

        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();

        $prompts = new PromptLibrary($this->settings);

        foreach ($todo as $field) {
            $maxLen = $field === 'title' ? $titleMax : $descMax;

            $promptKey = $field === 'title' ? PromptLibrary::KEY_META_TITLE : PromptLibrary::KEY_META_DESCRIPTION;
            $built = $prompts->build($promptKey, [
                '{sitename}' => $siteName,
                '{language}' => $locale,
                '{max_length}' => (string) $maxLen,
                '{post_title}' => $objectTitle,
                '{excerpt}' => $plainExcerpt,
                '{content}' => $plainContent,
                '{current_seo_title}' => $currentTitle,
                '{current_meta_description}' => $currentDesc,
            ]);

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

            // Enforce daily limits (best-effort).
            $limitError = $this->checkDailyLimits($prompt, $maxOutput);
            if ($limitError !== '') {
                $this->calls->insert([
                    'provider' => $provider,
                    'model' => $model,
                    'purpose' => $field,
                    'post_id' => $objectType === 'post' ? $objectId : 0,
                    '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' => $field,
                    'post_id' => $objectType === 'post' ? $objectId : 0,
                    'prompt_key' => $promptKey,
                    'prompt_version' => $promptVersion,
                    'prompt_hash' => $promptHash,
                    'success' => false,
                    'error_message' => $creditsError,
                ]);
                return ['status' => 'failed', 'error' => $creditsError];
            }

            $purpose = 'bulk_' . $field;
            if ($client instanceof SaaSAIProxyClient) {
                $resp = $client->generateText($model, $prompt, $maxOutput, $temperature, $purpose, [
                    'post_id' => $objectType === 'post' ? $objectId : 0,
                    'field' => $field,
                    'prompt_version' => $promptVersion,
                    'prompt_key' => $promptKey,
                    'prompt_hash' => $promptHash,
                ]);
            } else {
                $resp = $client->generateText($model, $prompt, $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);

            $this->calls->insert([
                'provider' => $provider,
                'model' => $model,
                'purpose' => $purpose,
                'post_id' => $objectType === 'post' ? $objectId : 0,
                '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' => !empty($resp['ok']),
                'error_message' => (string) ($resp['error'] ?? ''),
                'response_text' => $storeResponses ? (string) ($resp['text'] ?? '') : '',
            ]);

            if (empty($resp['ok'])) {
                $err = (string) ($resp['error'] ?? 'AI error');
                $httpStatus = isset($resp['http_status']) ? (int) $resp['http_status'] : 0;
                $retryAfter = isset($resp['retry_after']) ? (int) $resp['retry_after'] : 0;

                // Retry common transient errors.
                $attempts = isset($jobRow['attempts']) ? (int) $jobRow['attempts'] : 1;
                $attempts = max(1, $attempts);

                $isRateLimited = ($httpStatus === 429) || (stripos($err, 'rate') !== false) || (stripos($err, '429') !== false);
                $isTransient = $isRateLimited || (stripos($err, 'timeout') !== false) || (stripos($err, 'tempor') !== false);

                // Backoff base (seconds). For AJAX runs we can pass payload[backoff_seconds].
                $baseBackoff = isset($payload['backoff_seconds']) ? (int) $payload['backoff_seconds'] : 10;
                $baseBackoff = max(1, min(120, $baseBackoff));

                if ($isTransient) {
                    if ($isRateLimited) {
                        $base = $retryAfter > 0 ? $retryAfter : $baseBackoff;
                        // Exponential backoff by attempts (queue worker increments attempts automatically).
                        $retryIn = (int) min(1800, $base * (2 ** max(0, $attempts - 1)));

                        return [
                            'status' => 'retry',
                            'retry_in' => $retryIn,
                            'error' => $err,
                            'rate_limited' => true,
                            'retry_after' => $base,
                            'http_status' => $httpStatus,
                        ];
                    }

                    $retryIn = (int) min(1800, 60 * $attempts);
                    return ['status' => 'retry', 'retry_in' => $retryIn, 'error' => $err, 'http_status' => $httpStatus];
                }

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

            // 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' => 'bulk_' . $field,
                        'object_type' => $objectType,
                        'object_id' => $objectId,
                        'tokens_total' => $tokensTotal,
                        'cost_usd' => $cost,
                        'note' => 'bulk_generate',
                        'meta' => [
                            'job_id' => isset($jobRow['id']) ? (int) $jobRow['id'] : 0,
                        ],
                    ]);

                    if (!$ok) {
                        // Should be rare (race condition). Don't store suggestion.
                        return ['status' => 'failed', 'error' => __('AI credits depleted during processing.', 'wp-seo-next')];
                    }
                }
            }

            // Update daily usage from actual tokens.
            $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
                }
            }

            $text = $this->sanitizeAIOutput((string) ($resp['text'] ?? ''), $maxLen);

            $suggestion['fields'][$field] = [
                'text' => $text,
                'provider' => $provider,
                'prompt_version' => $promptVersion,
                'prompt_key' => $promptKey,
                'prompt_hash' => $promptHash,
                'tokens_in' => $tokensIn,
                'tokens_out' => $tokensOut,
                'tokens_total' => $tokensTotal,
                'cost_usd' => $cost,
            ];
        }

        $this->storeSuggestion($objectType, $objectId, $suggestion);

        if ($reportRepo instanceof BulkSessionReportRepository) {
            $reportRepo->recordGenerate($bulkSessionId, [
                'object_type' => $objectType,
                'object_id' => $objectId,
                'object_subtype' => $objectSubtype,
                'generate_status' => 'generated',
                'suggestion_source' => $source,
                'suggested_title' => isset($suggestion['fields']['title']['text']) ? (string) $suggestion['fields']['title']['text'] : '',
                'suggested_description' => isset($suggestion['fields']['description']['text']) ? (string) $suggestion['fields']['description']['text'] : '',
            ]);
        }

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

    /**
     * Queue handler: bulk_apply
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleApply($unused, array $payload, array $jobRow): array
    {
        $objectType = isset($payload['object_type']) ? sanitize_key((string) $payload['object_type']) : 'post';
        $objectId = isset($payload['object_id']) ? (int) $payload['object_id'] : 0;
        $objectSubtype = isset($payload['object_subtype']) ? sanitize_key((string) $payload['object_subtype']) : '';
        $bulkSessionId = isset($payload['bulk_session_id']) ? sanitize_key((string) $payload['bulk_session_id']) : '';

        if ($objectId <= 0 && isset($payload['post_id'])) {
            $objectType = 'post';
            $objectId = (int) $payload['post_id'];
        }

        if (!in_array($objectType, ['post', 'term'], true) || $objectId <= 0) {
            return ['status' => 'done'];
        }

        $reportRepo = $bulkSessionId !== '' ? new BulkSessionReportRepository() : null;
        if ($reportRepo instanceof BulkSessionReportRepository) {
            $reportRepo->touch($bulkSessionId);
        }

        $raw = $this->readObjectMeta($objectType, $objectId, self::META_SUGGESTIONS);
        if ($raw === '') {
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordApply($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'apply_status' => 'skipped_no_draft',
                    'apply_note' => __('Skipped because no generated suggestion draft was found for this object.', 'wp-seo-next'),
                ]);
            }
            return ['status' => 'done'];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordApply($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'apply_status' => 'skipped_invalid_draft',
                    'apply_note' => __('Skipped because the stored suggestion draft could not be parsed.', 'wp-seo-next'),
                ]);
            }
            return ['status' => 'done'];
        }

        $status = isset($data['status']) ? (string) $data['status'] : '';
        if ($status !== 'draft') {
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordApply($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'apply_status' => 'skipped_not_draft',
                    'apply_note' => __('Skipped because the suggestion was already applied or no longer in draft state.', 'wp-seo-next'),
                ]);
            }
            return ['status' => 'done'];
        }

        $fields = isset($data['fields']) && is_array($data['fields']) ? $data['fields'] : [];

        $title = isset($fields['title']['text']) ? trim((string) $fields['title']['text']) : '';
        $desc  = isset($fields['description']['text']) ? trim((string) $fields['description']['text']) : '';

        $title = self::repairCorruptedUnicodeEscapes($title);
        $desc  = self::repairCorruptedUnicodeEscapes($desc);

        $applyOnlyChanged = !array_key_exists('apply_only_changed', $payload) || (bool) $payload['apply_only_changed'];

        $currentTitle = $this->readObjectMeta($objectType, $objectId, MetaKeys::TITLE);
        $currentDesc  = $this->readObjectMeta($objectType, $objectId, MetaKeys::DESCRIPTION);

        $willUpdateTitle = ($title !== '' && (!$applyOnlyChanged || trim($title) !== trim($currentTitle)));
        $willUpdateDesc  = ($desc !== '' && (!$applyOnlyChanged || trim($desc) !== trim($currentDesc)));

        if ($title === '' && $desc === '') {
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordApply($bulkSessionId, [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'apply_status' => 'skipped_empty_suggestion',
                    'apply_note' => __('Skipped because the generated draft did not contain a title or description to apply.', 'wp-seo-next'),
                ]);
            }
            return ['status' => 'done'];
        }

        $didUpdate = false;
        $prevMeta = [
            MetaKeys::TITLE => $currentTitle,
            MetaKeys::DESCRIPTION => $currentDesc,
        ];

        if ($willUpdateTitle || $willUpdateDesc) {

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

                    $before = ['meta' => []];
                    $after  = ['meta' => []];

                    if ($willUpdateTitle) {
                        $before['meta'][MetaKeys::TITLE] = $currentTitle;
                        $after['meta'][MetaKeys::TITLE]  = $title;
                    }
                    if ($willUpdateDesc) {
                        $before['meta'][MetaKeys::DESCRIPTION] = $currentDesc;
                        $after['meta'][MetaKeys::DESCRIPTION]  = $desc;
                    }

                    if (!empty($before['meta'])) {
                        if ($objectType === 'post') {
                            $p = get_post($objectId);
                            if ($objectSubtype === '' && $p) {
                                $objectSubtype = (string) $p->post_type;
                            }
                        } else {
                            $t = get_term($objectId);
                            if ($objectSubtype === '' && $t && !is_wp_error($t)) {
                                $objectSubtype = (string) $t->taxonomy;
                            }
                        }

                        $revRepo->insert([
                            'object_type' => $objectType,
                            'object_id' => $objectId,
                            'object_subtype' => $objectSubtype,
                            'action' => 'apply',
                            'source' => 'bulk',
                            'actor_user_id' => (int) get_current_user_id(),
                            'before' => $before,
                            'after' => $after,
                            'meta' => [
                                'apply_only_changed' => $applyOnlyChanged,
                            ],
                        ]);
                    }
                } catch (\Throwable $e) {
                    // Never block applying suggestions if revisions logging fails.
                }
            }

            if ($objectType === 'post') {
                $history = [];

                $existingUndoRaw = $this->readObjectMeta($objectType, $objectId, self::META_UNDO);
                if ($existingUndoRaw !== '') {
                    $decodedUndo = json_decode($existingUndoRaw, true);
                    if (is_array($decodedUndo)) {
                        if (isset($decodedUndo['history']) && is_array($decodedUndo['history'])) {
                            foreach ($decodedUndo['history'] as $entry) {
                                if (is_array($entry) && isset($entry['meta']) && is_array($entry['meta'])) {
                                    $history[] = [
                                        'saved_at' => isset($entry['saved_at']) ? (string) $entry['saved_at'] : '',
                                        'meta' => [
                                            MetaKeys::TITLE => (string) ($entry['meta'][MetaKeys::TITLE] ?? ''),
                                            MetaKeys::DESCRIPTION => (string) ($entry['meta'][MetaKeys::DESCRIPTION] ?? ''),
                                        ],
                                    ];
                                }
                            }
                        } elseif (isset($decodedUndo['meta']) && is_array($decodedUndo['meta'])) {
                            $history[] = [
                                'saved_at' => isset($decodedUndo['saved_at']) ? (string) $decodedUndo['saved_at'] : '',
                                'meta' => [
                                    MetaKeys::TITLE => (string) ($decodedUndo['meta'][MetaKeys::TITLE] ?? ''),
                                    MetaKeys::DESCRIPTION => (string) ($decodedUndo['meta'][MetaKeys::DESCRIPTION] ?? ''),
                                ],
                            ];
                        }
                    }
                }

                $isDuplicate = false;
                if (!empty($history)) {
                    $last = $history[count($history) - 1];
                    if (is_array($last) && isset($last['meta']) && is_array($last['meta'])) {
                        $lastTitle = (string) ($last['meta'][MetaKeys::TITLE] ?? '');
                        $lastDesc  = (string) ($last['meta'][MetaKeys::DESCRIPTION] ?? '');
                        if ($lastTitle === (string) $prevMeta[MetaKeys::TITLE] && $lastDesc === (string) $prevMeta[MetaKeys::DESCRIPTION]) {
                            $isDuplicate = true;
                        }
                    }
                }

                if (!$isDuplicate) {
                    $history[] = [
                        'saved_at' => gmdate('c'),
                        'meta' => $prevMeta,
                    ];
                }

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

                $this->writeObjectMeta($objectType, $objectId, self::META_UNDO, $this->encodeJsonMeta([
                    'version' => 2,
                    'history' => $history,
                ]));
            }

            if ($willUpdateTitle) {
                $this->writeObjectMeta($objectType, $objectId, MetaKeys::TITLE, $title);
            }
            if ($willUpdateDesc) {
                $this->writeObjectMeta($objectType, $objectId, MetaKeys::DESCRIPTION, $desc);
            }

            $didUpdate = true;
        }

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

        $this->writeObjectMeta($objectType, $objectId, self::META_SUGGESTIONS, $this->encodeJsonMeta($data));
        $this->writeObjectMeta($objectType, $objectId, self::META_STATUS, 'applied');
        $this->writeObjectMeta($objectType, $objectId, self::META_UPDATED, time());

        if ($reportRepo instanceof BulkSessionReportRepository) {
            $reportRepo->recordApply($bulkSessionId, [
                'object_type' => $objectType,
                'object_id' => $objectId,
                'object_subtype' => $objectSubtype,
                'apply_status' => $didUpdate ? 'applied' : 'no_change',
                'before_title' => (string) $prevMeta[MetaKeys::TITLE],
                'before_description' => (string) $prevMeta[MetaKeys::DESCRIPTION],
                'after_title' => $willUpdateTitle ? $title : $currentTitle,
                'after_description' => $willUpdateDesc ? $desc : $currentDesc,
                'changed_fields' => array_values(array_filter([
                    $willUpdateTitle ? 'title' : '',
                    $willUpdateDesc ? 'description' : '',
                ])),
                'apply_note' => $didUpdate
                    ? __('Applied the generated suggestion to this object.', 'wp-seo-next')
                    : __('Suggestion matched the current SEO fields, so nothing changed.', 'wp-seo-next'),
            ]);
        }

        if ($didUpdate) {
            do_action('seo/meta/updated', $objectId, $objectType);

            try {
                $refreshPayload = [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'source' => 'bulk_apply',
                ];
                if ($bulkSessionId !== '') {
                    $refreshPayload['bulk_session_id'] = $bulkSessionId;
                }
                $this->queue->enqueue('audit_refresh_object_issues', $refreshPayload);
            } catch (\Throwable $e) {
                $this->logger->warning('Bulk apply could not enqueue audit refresh: ' . $e->getMessage(), [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                ]);
            }
        }

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

    private function storeSuggestion(string $objectType, int $objectId, array $suggestion): void
    {
        $this->writeObjectMeta($objectType, $objectId, self::META_SUGGESTIONS, $this->encodeJsonMeta($suggestion));
        $this->writeObjectMeta($objectType, $objectId, self::META_STATUS, 'draft');
        $this->writeObjectMeta($objectType, $objectId, self::META_SOURCE, isset($suggestion['source']) ? (string) $suggestion['source'] : '');
        $this->writeObjectMeta($objectType, $objectId, self::META_UPDATED, time());
    }

    private function readObjectMeta(string $objectType, int $objectId, string $metaKey): string
    {
        if ($objectType === 'term') {
            return (string) get_term_meta($objectId, $metaKey, true);
        }

        return (string) get_post_meta($objectId, $metaKey, true);
    }

    private function writeObjectMeta(string $objectType, int $objectId, string $metaKey, $value): void
    {
        if ($objectType === 'term') {
            update_term_meta($objectId, $metaKey, $value);
            return;
        }

        update_post_meta($objectId, $metaKey, $value);
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<int,string>
     */
    private function normalizeIssueCodes(array $payload): array
    {
        $codes = [];
        if (isset($payload['issue_codes']) && is_array($payload['issue_codes'])) {
            foreach ($payload['issue_codes'] as $code) {
                $code = sanitize_key((string) $code);
                if ($code !== '') {
                    $codes[$code] = 1;
                }
            }
        }
        if (isset($payload['issue_code'])) {
            $code = sanitize_key((string) $payload['issue_code']);
            if ($code !== '') {
                $codes[$code] = 1;
            }
        }

        return array_keys($codes);
    }

    /**
     * @param array<string,mixed> $suggestion
     * @param array<string,mixed> $context
     * @return array<string,mixed>
     */
    private function refineLocalSuggestions(array $suggestion, array $context): array
    {
        $objectType = isset($context['object_type']) ? sanitize_key((string) $context['object_type']) : 'post';
        $objectId = isset($context['object_id']) ? (int) $context['object_id'] : 0;
        $objectSubtype = isset($context['object_subtype']) ? sanitize_key((string) $context['object_subtype']) : '';
        $objectTitle = trim((string) ($context['object_title'] ?? ''));
        $currentTitle = trim((string) ($context['current_title'] ?? ''));
        $currentDescription = trim((string) ($context['current_description'] ?? ''));
        $titleMax = isset($context['title_max_length']) ? (int) $context['title_max_length'] : 60;
        $descMax = isset($context['description_max_length']) ? (int) $context['description_max_length'] : 155;
        $issueCodes = isset($context['issue_codes']) && is_array($context['issue_codes']) ? array_values($context['issue_codes']) : [];
        $keywords = isset($suggestion['keywords']) && is_array($suggestion['keywords']) ? array_values(array_filter(array_map('trim', $suggestion['keywords']))) : [];

        $title = trim((string) ($suggestion['title'] ?? ''));
        $description = trim((string) ($suggestion['description'] ?? ''));

        $titleIssues = false;
        $descriptionIssues = false;
        foreach ($issueCodes as $issueCode) {
            $mapped = self::fieldsForIssueCode((string) $issueCode);
            if (in_array('title', $mapped, true)) {
                $titleIssues = true;
            }
            if (in_array('description', $mapped, true)) {
                $descriptionIssues = true;
            }
        }

        if ($title === '' || $titleIssues) {
            $title = $this->buildLocalTitleCandidate($title, $objectTitle, $currentTitle, $objectType, $objectId, $objectSubtype, $keywords, $titleMax, $issueCodes);
        } else {
            $title = $this->trimTextToLength($title, $titleMax);
        }

        if ($description === '' || $descriptionIssues) {
            $description = $this->buildLocalDescriptionCandidate($description, $objectTitle, $currentDescription, $objectType, $objectId, $objectSubtype, $keywords, $descMax, $issueCodes);
        } else {
            $description = $this->trimTextToLength($description, $descMax);
        }

        $suggestion['title'] = $title;
        $suggestion['description'] = $description;

        return $suggestion;
    }

    /**
     * @param array<int,string> $keywords
     * @param array<int,string> $issueCodes
     */
    private function buildLocalTitleCandidate(string $base, string $objectTitle, string $currentTitle, string $objectType, int $objectId, string $objectSubtype, array $keywords, int $maxLen, array $issueCodes = []): string
    {
        $seed = trim($base) !== '' ? trim($base) : trim($objectTitle);
        if ($seed === '' && !empty($keywords)) {
            $seed = trim((string) $keywords[0]);
        }
        if ($seed === '') {
            $seed = __('Untitled', 'wp-seo-next');
        }

        $thresholds = $this->auditLengthThresholds('title');
        $minLen = (int) ($thresholds['min'] ?? 20);
        $siteName = trim((string) get_bloginfo('name'));
        $currentNorm = $this->normalizeComparableText($currentTitle);
        $issueMap = array_fill_keys(array_values(array_filter(array_map('sanitize_key', $issueCodes))), true);
        $needsUnique = isset($issueMap['duplicate_title']);
        $needsMinLength = isset($issueMap['title_too_short']) || isset($issueMap['missing_title']) || $this->textLength($seed) < $minLen;

        $descriptor = $this->objectTitleDescriptor($objectType, $objectSubtype, $objectId);
        $slugWords = $this->objectSlugWordsLabel($objectType, $objectId, $objectSubtype);

        $candidates = [];
        $push = static function (array &$list, string $candidate): void {
            $candidate = trim($candidate);
            if ($candidate === '') {
                return;
            }
            $list[$candidate] = $candidate;
        };

        if ($needsUnique && $slugWords !== '' && $this->normalizeComparableText($slugWords) !== $this->normalizeComparableText($seed)) {
            $push($candidates, $slugWords);
            if ($descriptor !== '' && !$this->textContains($slugWords, $descriptor)) {
                $push($candidates, $slugWords . ' ' . $descriptor);
            }
            if ($siteName !== '') {
                $push($candidates, $slugWords . ' | ' . $siteName);
                if ($descriptor !== '' && !$this->textContains($slugWords, $descriptor)) {
                    $push($candidates, $slugWords . ' ' . $descriptor . ' | ' . $siteName);
                }
            }
        }

        $push($candidates, $seed);
        if ($descriptor !== '' && !$this->textContains($seed, $descriptor)) {
            $push($candidates, $seed . ' ' . $descriptor);
        }
        if ($siteName !== '') {
            $push($candidates, $seed . ' | ' . $siteName);
            if ($descriptor !== '' && !$this->textContains($seed, $descriptor)) {
                $push($candidates, $seed . ' ' . $descriptor . ' | ' . $siteName);
            }
        }
        if ($descriptor !== '' && $siteName !== '' && !$this->textContains($seed, $descriptor)) {
            $push($candidates, $seed . ' – ' . $descriptor . ' | ' . $siteName);
        }
        if ($needsUnique && $slugWords !== '' && $this->normalizeComparableText($slugWords) !== $this->normalizeComparableText($seed)) {
            $push($candidates, $seed . ' – ' . $slugWords);
            if ($siteName !== '') {
                $push($candidates, $seed . ' – ' . $slugWords . ' | ' . $siteName);
            }
        }

        $best = '';
        $bestScore = -1000000;
        foreach (array_values($candidates) as $candidate) {
            $candidate = $this->trimTextToLength($candidate, $maxLen);
            if ($candidate === '') {
                continue;
            }

            $norm = $this->normalizeComparableText($candidate);
            $len = $this->textLength($candidate);
            $sameAsCurrent = ($currentNorm !== '' && $norm === $currentNorm);
            $sameAsSeed = ($norm === $this->normalizeComparableText($seed));
            $looksUnique = !$needsUnique || ($slugWords !== '' && $this->textContains($candidate, $slugWords));
            $meetsMin = $len >= $minLen;

            $score = 0;
            if (!$sameAsCurrent) {
                $score += 200;
            }
            if ($meetsMin) {
                $score += 150;
            } elseif ($needsMinLength) {
                $score -= ($minLen - $len) * 8;
            }
            if ($looksUnique) {
                $score += 100;
            }
            if ($siteName !== '' && $this->textContains($candidate, $siteName)) {
                $score += 25;
            }
            if ($descriptor !== '' && $this->textContains($candidate, $descriptor)) {
                $score += 20;
            }
            if ($sameAsSeed && ($needsUnique || $needsMinLength)) {
                $score -= 40;
            }
            $score += min($len, $maxLen);

            if ($score > $bestScore) {
                $bestScore = $score;
                $best = $candidate;
            }
        }

        if ($best === '') {
            $best = $this->trimTextToLength($seed, $maxLen);
        }

        if ($this->normalizeComparableText($best) === $currentNorm && $siteName !== '' && !$this->textContains($best, $siteName)) {
            $fallback = $this->trimTextToLength($best . ' | ' . $siteName, $maxLen);
            if ($fallback !== '') {
                $best = $fallback;
            }
        }

        return $this->trimTextToLength($best, $maxLen);
    }

    /**
     * @param array<int,string> $keywords
     * @param array<int,string> $issueCodes
     */
    private function buildLocalDescriptionCandidate(string $base, string $objectTitle, string $currentDescription, string $objectType, int $objectId, string $objectSubtype, array $keywords, int $maxLen, array $issueCodes = []): string
    {
        $thresholds = $this->auditLengthThresholds('description');
        $minLen = (int) ($thresholds['min'] ?? 50);
        $currentNorm = $this->normalizeComparableText($currentDescription);
        $issueMap = array_fill_keys(array_values(array_filter(array_map('sanitize_key', $issueCodes))), true);
        $needsUnique = isset($issueMap['duplicate_description']);
        $needsMinLength = isset($issueMap['description_too_short']) || isset($issueMap['missing_description']);

        $siteName = trim((string) get_bloginfo('name'));
        $slugWords = $this->objectSlugWordsLabel($objectType, $objectId, $objectSubtype);
        $descriptor = $this->objectDescriptionDescriptor($objectType, $objectSubtype, $objectId);
        $title = trim($objectTitle);
        if ($title === '') {
            $title = $slugWords !== '' ? $slugWords : __('This page', 'wp-seo-next');
        }

        $base = trim($base);
        $candidates = [];
        $push = static function (array &$list, string $candidate): void {
            $candidate = trim($candidate);
            if ($candidate === '') {
                return;
            }
            $list[$candidate] = $candidate;
        };

        if ($base !== '') {
            $push($candidates, $base);
        }

        $subject = $title;
        if ($needsUnique && $slugWords !== '' && !$this->textContains($title, $slugWords)) {
            $subject = $slugWords;
        }

        if ($objectType === 'term') {
            $push($candidates, sprintf(
                __('Explore %1$s on %2$s. Compare tools, features, use cases, and workflows related to %1$s.', 'wp-seo-next'),
                $subject,
                $siteName !== '' ? $siteName : __('this site', 'wp-seo-next')
            ));
            $push($candidates, sprintf(
                __('Browse %1$s resources on %2$s. Find tools, practical use cases, and category context for %1$s.', 'wp-seo-next'),
                $subject,
                $siteName !== '' ? $siteName : __('this site', 'wp-seo-next')
            ));
            if ($descriptor !== '') {
                $push($candidates, sprintf(
                    __('Explore %1$s on %2$s. Review %3$s, workflows, and fit to choose the right option.', 'wp-seo-next'),
                    $subject,
                    $siteName !== '' ? $siteName : __('this site', 'wp-seo-next'),
                    $descriptor
                ));
            }
        } else {
            $push($candidates, sprintf(
                __('Learn about %1$s on %2$s. Get features, use cases, and practical context in one place.', 'wp-seo-next'),
                $subject,
                $siteName !== '' ? $siteName : __('this site', 'wp-seo-next')
            ));
            if ($descriptor !== '') {
                $push($candidates, sprintf(
                    __('Discover %1$s on %2$s. Review %3$s, key details, and practical guidance.', 'wp-seo-next'),
                    $subject,
                    $siteName !== '' ? $siteName : __('this site', 'wp-seo-next'),
                    $descriptor
                ));
            }
        }

        if (!empty($keywords)) {
            $keywordPhrase = implode(', ', array_slice(array_values(array_filter(array_map('trim', $keywords))), 0, 3));
            if ($keywordPhrase !== '') {
                $push($candidates, sprintf(
                    __('Explore %1$s on %2$s. Compare %3$s and related workflows to find the best fit.', 'wp-seo-next'),
                    $subject,
                    $siteName !== '' ? $siteName : __('this site', 'wp-seo-next'),
                    $keywordPhrase
                ));
            }
        }

        $best = '';
        $bestScore = -1000000;
        foreach (array_values($candidates) as $candidate) {
            $candidate = $this->trimTextToLength($candidate, $maxLen);
            if ($candidate === '') {
                continue;
            }
            $norm = $this->normalizeComparableText($candidate);
            $len = $this->textLength($candidate);
            $sameAsCurrent = ($currentNorm !== '' && $norm === $currentNorm);
            $looksUnique = !$needsUnique || ($slugWords !== '' && $this->textContains($candidate, $slugWords));
            $meetsMin = $len >= $minLen;

            $score = 0;
            if (!$sameAsCurrent) {
                $score += 200;
            }
            if ($meetsMin) {
                $score += 150;
            } elseif ($needsMinLength) {
                $score -= ($minLen - $len) * 6;
            }
            if ($looksUnique) {
                $score += 100;
            }
            if ($siteName !== '' && $this->textContains($candidate, $siteName)) {
                $score += 15;
            }
            if ($descriptor !== '' && $this->textContains($candidate, $descriptor)) {
                $score += 10;
            }
            $score += min($len, $maxLen);

            if ($score > $bestScore) {
                $bestScore = $score;
                $best = $candidate;
            }
        }

        if ($best === '') {
            $best = $this->trimTextToLength($base !== '' ? $base : $title, $maxLen);
        }

        return $this->trimTextToLength($best, $maxLen);
    }

    private function auditLengthThresholds(string $field): array
    {
        $field = $field === 'description' ? 'description' : 'title';
        if ($field === 'description') {
            $min = max(0, min(400, (int) apply_filters('wpsn/audit/description_min_len', 50)));
            $max = max(40, min(400, (int) apply_filters('wpsn/audit/description_max_len', 160)));
            if ($max < $min) {
                $max = $min;
            }
            return ['min' => $min, 'max' => $max];
        }

        $min = max(0, min(120, (int) apply_filters('wpsn/audit/title_min_len', 20)));
        $max = max(10, min(200, (int) apply_filters('wpsn/audit/title_max_len', 60)));
        if ($max < $min) {
            $max = $min;
        }
        return ['min' => $min, 'max' => $max];
    }

    private function objectTitleDescriptor(string $objectType, string $objectSubtype, int $objectId): string
    {
        $descriptor = '';
        if ($objectType === 'term') {
            if (in_array($objectSubtype, ['tool_tag', 'tool_category'], true)) {
                $descriptor = __('Tools', 'wp-seo-next');
            } else {
                $descriptor = $this->taxonomyLabel($objectSubtype);
            }
        } else {
            $descriptor = $this->postTypeLabel($objectId, $objectSubtype);
        }

        return trim((string) $descriptor);
    }

    private function objectDescriptionDescriptor(string $objectType, string $objectSubtype, int $objectId): string
    {
        if ($objectType === 'term') {
            if ($objectSubtype === 'tool_tag') {
                return __('tools, features, and use cases', 'wp-seo-next');
            }
            if ($objectSubtype === 'tool_category') {
                return __('tools, comparisons, and buying context', 'wp-seo-next');
            }
            $label = $this->taxonomyLabel($objectSubtype);
            return $label !== '' ? strtolower($label) : __('details and context', 'wp-seo-next');
        }

        $label = $this->postTypeLabel($objectId, $objectSubtype);
        return $label !== '' ? strtolower($label) : __('details and context', 'wp-seo-next');
    }

    private function objectSlugWordsLabel(string $objectType, int $objectId, string $objectSubtype): string
    {
        $slug = '';
        if ($objectType === 'term') {
            $term = $objectSubtype !== '' ? get_term($objectId, $objectSubtype) : get_term($objectId);
            if ($term && !is_wp_error($term) && isset($term->slug)) {
                $slug = (string) $term->slug;
            }
        } else {
            $post = get_post($objectId);
            if ($post && isset($post->post_name)) {
                $slug = (string) $post->post_name;
            }
        }

        $slug = trim($slug);
        if ($slug === '') {
            return '';
        }

        $parts = preg_split('/[-_]+/', $slug) ?: [];
        $parts = array_values(array_filter(array_map('trim', $parts), static fn ($v) => $v !== ''));
        if (empty($parts)) {
            $parts = [$slug];
        }

        $words = [];
        foreach ($parts as $part) {
            if ($part === '') {
                continue;
            }
            if (preg_match('/^[a-z]{1,4}$/i', $part)) {
                $words[] = strtoupper($part);
            } elseif (preg_match('/^[a-z0-9]+$/i', $part)) {
                $words[] = ucwords(strtolower($part));
            } else {
                $words[] = $part;
            }
        }

        return trim(implode(' ', $words));
    }

    private function taxonomyLabel(string $taxonomy): string
    {
        if ($taxonomy === '') {
            return '';
        }
        $tax = get_taxonomy($taxonomy);
        if ($tax && isset($tax->labels) && is_object($tax->labels) && isset($tax->labels->singular_name)) {
            return trim((string) $tax->labels->singular_name);
        }
        return trim((string) $taxonomy);
    }

    private function postTypeLabel(int $postId, string $fallback): string
    {
        $postType = $fallback !== '' ? $fallback : get_post_type($postId);
        if ($postType === '') {
            return '';
        }
        $obj = get_post_type_object($postType);
        if ($obj && isset($obj->labels) && is_object($obj->labels) && isset($obj->labels->singular_name)) {
            return trim((string) $obj->labels->singular_name);
        }
        return trim((string) $postType);
    }

    private function normalizeComparableText(string $text): string
    {
        $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
        $text = preg_replace('/\s+/u', ' ', (string) $text) ?? (string) $text;
        return trim((string) $text);
    }

    private function textContains(string $haystack, string $needle): bool
    {
        $haystack = $this->normalizeComparableText($haystack);
        $needle = $this->normalizeComparableText($needle);
        if ($haystack === '' || $needle === '') {
            return false;
        }
        if (function_exists('mb_strpos')) {
            return mb_strpos($haystack, $needle) !== false;
        }
        return strpos($haystack, $needle) !== false;
    }

    private function textLength(string $text): int
    {
        if (function_exists('mb_strlen')) {
            return (int) mb_strlen($text);
        }
        return (int) strlen($text);
    }

    private function trimTextToLength(string $text, int $maxLen): string
    {
        $text = trim((string) $text);
        if ($text === '' || $maxLen <= 0) {
            return '';
        }
        if ($this->textLength($text) <= $maxLen) {
            return $text;
        }
        if (function_exists('mb_substr')) {
            $cut = (string) mb_substr($text, 0, $maxLen);
        } else {
            $cut = substr($text, 0, $maxLen);
        }
        $cut = preg_replace('/\s+\S*$/u', '', $cut) ?? $cut;
        $cut = trim((string) $cut);
        return $cut !== '' ? rtrim($cut, "	

\0-–—,.;:") . '…' : $text;
    }

    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 (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 renderPrompt(string $template, array $vars): string
    {
        return (string) strtr($template, $vars);
    }

    private function defaultPromptTemplate(string $field): string
    {
        if ($field === 'title') {
            return "You are an SEO assistant. Write a concise SEO title for the following WordPress page.\n\nRules:\n- Language: {language}\n- Max length: {max_length} characters\n- Do NOT use quotes\n- Do not add extra commentary\n\nPage:\nSite name: {sitename}\nPost title: {post_title}\nExcerpt: {excerpt}\nContent: {content}\nCurrent SEO title: {current_seo_title}\n\nReturn only the SEO title.";
        }

        return "You are an SEO assistant. Write a helpful meta description for the following WordPress page.\n\nRules:\n- Language: {language}\n- Max length: {max_length} characters\n- Do NOT use quotes\n- Do not add extra commentary\n\nPage:\nSite name: {sitename}\nPost title: {post_title}\nExcerpt: {excerpt}\nContent: {content}\nCurrent meta description: {current_meta_description}\n\nReturn only the meta description.";
    }

    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
    {
        if ($priceInPer1m <= 0 && $priceOutPer1m <= 0) {
            return 0.0;
        }
        $cost = (($tokensIn * $priceInPer1m) + ($tokensOut * $priceOutPer1m)) / 1000000;
        return (float) $cost;
    }

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

        $reqLimit = (int) $this->settings->get('ai.external.daily_request_limit', 0);
        $tokenLimit = (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);

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

        if ($reqLimit > 0 && (int) ($usage['requests'] ?? 0) + 1 > $reqLimit) {
            return __('Daily AI request limit reached.', 'wp-seo-next');
        }
        if ($tokenLimit > 0 && (int) ($usage['tokens_total'] ?? 0) + $estIn + $estOut > $tokenLimit) {
            return __('Daily AI token limit reached.', 'wp-seo-next');
        }
        if ($budgetUsd > 0 && (float) ($usage['cost_usd'] ?? 0.0) + $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. We add +10% and at least 50 tokens.
        $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 '';
    }


    private function resolveStoredSuggestionStatus(string $objectType, int $objectId): string
    {
        $status = sanitize_key($this->readObjectMeta($objectType, $objectId, self::META_STATUS));
        if ($status !== '') {
            return $status;
        }

        $raw = $this->readObjectMeta($objectType, $objectId, self::META_SUGGESTIONS);
        if ($raw === '') {
            return '';
        }

        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return '';
        }

        return sanitize_key((string) ($decoded['status'] ?? ''));
    }

    /**
     * @return array<int,string>
     */
    private static function fieldsForIssueCode(string $issueCode): array
    {
        return AuditJobs::fieldsForIssueCode($issueCode);
    }

    private function sanitizeAIOutput(string $text, int $maxLen): string
    {
        $t = trim((string) $text);
        // Strip wrapping quotes.
        $t = preg_replace('/^"(.*)"$/s', '$1', $t);
        $t = preg_replace('/^\'(.*)\'$/s', '$1', $t);
        $t = trim((string) $t);

        // Collapse whitespace.
        $t = preg_replace('/\s+/', ' ', (string) $t);
        $t = trim((string) $t);

        // Enforce length.
        if ($maxLen > 0) {
            if (function_exists('mb_strlen') && function_exists('mb_substr')) {
                if (mb_strlen($t) > $maxLen) {
                    $t = (string) mb_substr($t, 0, $maxLen);
                }
            } else {
                if (strlen($t) > $maxLen) {
                    $t = substr($t, 0, $maxLen);
                }
            }
        }

        return $t;
    }
}
