<?php

namespace WPSN\Modules\AI;

use WPSN\Core\AbstractModule;
use WPSN\Modules\AI\Admin\AIAdmin;
use WPSN\SEO\MetaKeys;
use WPSN\SEO\ContentExtractor;
use WPSN\Core\Capabilities;
use WPSN\Core\RateLimiter;
use WPSN\Modules\SaaSSync\SaaSSyncService;

defined('ABSPATH') || exit;

/**
 * Stage 10A/10B: Local suggestions + external AI provider (OpenAI or SaaS proxy).
 */
final class AIModule extends AbstractModule
{
    private AISecretsRepository $secrets;
    private AIUsageRepository $usage;
    private AICallRepository $calls;
    private LocalSuggestionEngine $local;

    public function __construct(\WPSN\Core\Container $container)
    {
        parent::__construct($container);

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

    public function id(): string
    {
        return 'ai';
    }

    public function name(): string
    {
        return __('AI', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('Local suggestions + OpenAI generation (optional).', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return false;
    }

    public function register(): void
    {
        // Ensure DB exists.
        add_action('init', [AIInstaller::class, 'maybeInstall'], 1);

        // REST endpoints.
        add_action('rest_api_init', [$this, 'registerRestRoutes']);

        // Admin UI.
        if (is_admin()) {
            (new AIAdmin($this->secrets, $this->usage, $this->calls, $this->logger, $this->settings))->register();
        }
    }

    public function registerRestRoutes(): void
    {
        register_rest_route('wpsn/v1', '/suggest', [
            'methods'             => 'POST',
            'permission_callback' => function (\WP_REST_Request $request): bool {
                $postId = (int) $request->get_param('post_id');
                // Local suggestions are safe (rule-based) and should work for any user who can edit the post.
                return $postId > 0 && current_user_can('edit_post', $postId);
            },
            'callback'            => function (\WP_REST_Request $request) {
                if (!(bool) $this->settings->get('ai.local.enabled', false)) {
                    return new \WP_Error('wpsn_ai_local_disabled', __('Local suggestions are disabled in Settings.', 'wp-seo-next'), ['status' => 403]);
                }

                $postId = (int) $request->get_param('post_id');
                $data = $this->getPostDataFromRequest($postId, $request);

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

                $suggestions = $this->local->suggest([
                    'post_id' => $postId,
                    'post_title' => $data['title'],
                    'excerpt' => $data['excerpt'],
                    'content' => $data['content'],
                    'title_max_length' => $titleMax,
                    'description_max_length' => $descMax,
                    'keywords_limit' => $kwLimit,
                ]);

                return rest_ensure_response($suggestions);
            },
            'args'                => [
                'post_id' => ['type' => 'integer', 'required' => true],
                'title'   => ['type' => 'string', 'required' => false],
                'content' => ['type' => 'string', 'required' => false],
                'excerpt' => ['type' => 'string', 'required' => false],
            ],
        ]);

        register_rest_route('wpsn/v1', '/ai/generate', [
            'methods'             => 'POST',
            'permission_callback' => function (\WP_REST_Request $request): bool {
                $postId = (int) $request->get_param('post_id');
                return $postId > 0 && current_user_can('edit_post', $postId) && Capabilities::canUseAI();
            },
            'callback'            => function (\WP_REST_Request $request) {
                if ((bool) $this->settings->get('privacy.local_only', false)) {
                    return new \WP_Error('wpsn_privacy_local_only', __('External services are disabled by Local-only mode.', 'wp-seo-next'), ['status' => 403]);
                }

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

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

                $postId = (int) $request->get_param('post_id');

                if ((bool) $this->settings->get('ai.privacy.no_drafts', true)) {
                    $status = get_post_status($postId);
                    if (!in_array($status, ['publish', 'private'], true)) {
                        return new \WP_Error('wpsn_ai_drafts_blocked', __('AI generation is blocked for drafts by privacy setting.', 'wp-seo-next'), ['status' => 403]);
                    }
                }

                $apiKey = '';
                $saas = null;

                if ($provider === 'openai') {
                    $apiKey = $this->secrets->getOpenAIKey();
                    if ($apiKey === '') {
                        return new \WP_Error('wpsn_ai_no_key', __('OpenAI API key is not configured.', 'wp-seo-next'), ['status' => 400]);
                    }
                } elseif ($provider === 'saas_proxy') {
                    // Require SaaS Sync to be enabled + trusted.
                    if (!$this->settings->moduleEnabled('saas_sync', false) || !(bool) $this->settings->get('saas_sync.enabled', false)) {
                        return new \WP_Error('wpsn_ai_saas_disabled', __('SaaS Sync module is disabled.', 'wp-seo-next'), ['status' => 403]);
                    }

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

                $field = sanitize_key((string) $request->get_param('field'));
                if (!in_array($field, ['title', 'description'], true)) {
                    return new \WP_Error('wpsn_ai_field', __('Invalid field. Use title or description.', 'wp-seo-next'), ['status' => 400]);
                }

                $data = $this->getPostDataFromRequest($postId, $request);

                $metaIn = $request->get_param('meta');
                $meta = is_array($metaIn) ? $metaIn : [];

                $currentSeoTitle = isset($meta[MetaKeys::TITLE]) ? (string) $meta[MetaKeys::TITLE] : '';
                $currentDesc = isset($meta[MetaKeys::DESCRIPTION]) ? (string) $meta[MetaKeys::DESCRIPTION] : '';

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

                $maxContentChars = (int) $this->settings->get('ai.local.max_content_chars', 12000);
                if ($maxContentChars < 2000) {
                    $maxContentChars = 2000;
                }

                $plainContent = $this->local->extractPlainText($data['content']);
                $plainExcerpt = $this->local->extractPlainText($data['excerpt']);

                $plainContent = $this->truncateChars($plainContent, $maxContentChars);

                $maxLen = ($field === 'title')
                    ? (int) $this->settings->get('ai.local.title_max_length', 60)
                    : (int) $this->settings->get('ai.local.description_max_length', 155);

                $locale = get_locale();
                $siteName = get_bloginfo('name');

                $promptKey = $field === 'title' ? PromptLibrary::KEY_META_TITLE : PromptLibrary::KEY_META_DESCRIPTION;

                $prompts = new PromptLibrary($this->settings);
                $built = $prompts->build($promptKey, [
                    '{sitename}' => (string) $siteName,
                    '{language}' => (string) $locale,
                    '{max_length}' => (string) (int) $maxLen,
                    '{post_title}' => (string) $data['title'],
                    '{excerpt}' => (string) $plainExcerpt,
                    '{content}' => (string) $plainContent,
                    '{current_seo_title}' => (string) $currentSeoTitle,
                    '{current_meta_description}' => (string) $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 estimate).
                $usage = $this->usage->getToday();

                $tokenLimit = (int) $this->settings->get('ai.external.daily_token_limit', 0);
                $reqLimit   = (int) $this->settings->get('ai.external.daily_request_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, $maxOutput);
                $estTotal = $estIn + $estOut;
                $estCost = $this->estimateCost($estIn, $estOut, $priceIn, $priceOut);

                if ($reqLimit > 0 && (int) ($usage['requests'] ?? 0) + 1 > $reqLimit) {
                    return new \WP_Error('wpsn_ai_limit_requests', __('Daily AI request limit reached.', 'wp-seo-next'), ['status' => 429]);
                }
                if ($tokenLimit > 0 && (int) ($usage['tokens_total'] ?? 0) + $estTotal > $tokenLimit) {
                    return new \WP_Error('wpsn_ai_limit_tokens', __('Daily AI token limit reached.', 'wp-seo-next'), ['status' => 429]);
                }
                if ($budgetUsd > 0 && (float) ($usage['cost_usd'] ?? 0.0) + $estCost > $budgetUsd) {
                    return new \WP_Error('wpsn_ai_limit_budget', __('Daily AI budget limit reached.', 'wp-seo-next'), ['status' => 429]);
                }

                // Optional credits enforcement (local wallet).
                // NOTE: when using SaaS proxy, billing is expected to happen on the SaaS side.
                $creditsRepo = new AICreditsRepository($this->settings);
                $localCreditsEnabled = ($provider === 'openai') && $creditsRepo->isEnabled();
                if ($localCreditsEnabled) {
                    $margin = max(50, (int) ceil(($estIn + $estOut) * 0.10));
                    $estInSafe = (int) ceil($estIn * 1.10);
                    $estOutSafe = $estOut + $margin;
                    $estCostSafe = $this->estimateCost($estInSafe, $estOutSafe, $priceIn, $priceOut);

                    $neededCredits = $creditsRepo->estimateRequiredCredits($estInSafe, $estOutSafe, $estCostSafe);
                    if ($neededCredits > 0.0 && !$creditsRepo->canAfford($neededCredits)) {
                        return new \WP_Error(
                            'wpsn_ai_credits_depleted',
                            sprintf(
                                /* translators: 1: needed credits, 2: current balance */
                                __('AI credits depleted. Need ~%1$s credits, balance %2$s.', 'wp-seo-next'),
                                (string) $neededCredits,
                                (string) $creditsRepo->getBalance()
                            ),
                            ['status' => 402]
                        );
                    }
                }

                // Best-effort server-side rate limiting for the editor generation endpoint.
                $rlEnabled = (bool) $this->settings->get('ai.external.ratelimit.enabled', false);
                $rlRpm = (int) $this->settings->get('ai.external.ratelimit.requests_per_minute', 0);
                $rlPerUser = (bool) $this->settings->get('ai.external.ratelimit.per_user', true);
                if ($rlEnabled && $rlRpm > 0) {
                    $scope = $rlPerUser ? ('u_' . (string) (int) get_current_user_id()) : 'global';
                    $key = 'wpsn_ai_generate_' . $scope;
                    $lim = (new RateLimiter())->hit($key, $rlRpm, 60);
                    if (empty($lim['allowed'])) {
                        $retry = (int) ($lim['retry_after'] ?? 10);
                        $resp = new \WP_REST_Response([
                            'code' => 'wpsn_ai_rate_limited',
                            'message' => __('Rate limit reached. Please wait a moment and try again.', 'wp-seo-next'),
                            'retry_after' => $retry,
                            'limit' => (int) ($lim['limit'] ?? $rlRpm),
                        ], 429);
                        $resp->header('Retry-After', (string) max(1, $retry));
                        return $resp;
                    }
                }

                $org = (string) $this->settings->get('ai.external.openai_org', '');
                $project = (string) $this->settings->get('ai.external.openai_project', '');

                // Call provider.
                if ($provider === 'openai') {
                    $client = new OpenAIClient($apiKey, $org, $project, $this->logger);
                    $res = $client->generateText($model, $prompt, $maxOutput, $temperature);
                } else {
                    // SaaS proxy / remote AI hub.
                    $client = new SaaSAIProxyClient($saas, $this->logger, $this->settings);
                    $structured = $this->buildStructuredGenerateRequest(
                        $postId,
                        $field,
                        $data,
                        $meta,
                        $plainContent,
                        $plainExcerpt,
                        $siteName,
                        $locale,
                        $maxLen,
                        $currentSeoTitle,
                        $currentDesc,
                        $prompt,
                        $promptKey,
                        $promptVersion,
                        $promptHash
                    );
                    $res = $client->generateStructured($model, $structured, $maxOutput, $temperature, 'editor_' . $field, [
                        'post_id' => $postId,
                        'field' => $field,
                        'prompt_version' => $promptVersion,
                        'prompt_key' => $promptKey,
                        'prompt_hash' => $promptHash,
                    ]);

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

                $ok = !empty($res['ok']);
                $text = isset($res['text']) ? trim((string) $res['text']) : '';

                // Basic cleanup: remove quotes/newlines.
                $text = preg_replace('/^\s*["“”\']+|["“”\']+\s*$/u', '', $text) ?? $text;
                $text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text);

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

                // Fallback if provider did not return usage.
                if ($tokensTotal <= 0) {
                    $tokensIn = $estIn;
                    $tokensOut = min($estOut, $this->estimateTokens($text));
                    $tokensTotal = $tokensIn + $tokensOut;
                }

                $costUsd = isset($res['cost_usd']) ? (float) $res['cost_usd'] : $this->estimateCost($tokensIn, $tokensOut, $priceIn, $priceOut);

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

                $this->calls->insert([
                    'created_at' => gmdate('Y-m-d H:i:s'),
                    'provider' => $provider,
                    'model' => $model,
                    'purpose' => $field,
                    'post_id' => $postId,
                    'prompt_key' => $promptKey,
                    'prompt_version' => $promptVersion,
                    'prompt_hash' => $promptHash,
                    'tokens_in' => $tokensIn,
                    'tokens_out' => $tokensOut,
                    'tokens_total' => $tokensTotal,
                    'cost_usd' => $costUsd,
                    'latency_ms' => (int) ($res['latency_ms'] ?? 0),
                    'success' => $ok ? 1 : 0,
                    'error_message' => $ok ? '' : (string) ($res['error'] ?? ''),
                    'response_text' => ($storeResponses && $ok) ? $this->truncateChars($text, 4000) : '',
                ]);

                if (!$ok) {
                    $msg = (string) ($res['error'] ?? __('AI generation failed.', 'wp-seo-next'));
                    return new \WP_Error('wpsn_ai_failed', $msg, ['status' => 502]);
                }

                // Debit credits (optional).
                $creditsWarning = '';
                $creditsRemaining = null;
                if ($localCreditsEnabled) {
                    $creditsUsed = $creditsRepo->actualRequiredCredits($tokensTotal, $costUsd);
                    if ($creditsUsed > 0.0) {
                        $okSpend = $creditsRepo->spend($creditsUsed, [
                            'user_id' => (int) get_current_user_id(),
                            'mode' => $creditsRepo->mode(),
                            'provider' => $provider,
                            'model' => $model,
                            'purpose' => 'editor_' . $field,
                            'object_type' => 'post',
                            'object_id' => $postId,
                            'tokens_total' => $tokensTotal,
                            'cost_usd' => $costUsd,
                            'note' => 'editor_generate',
                            'meta' => [
                                'prompt_version' => $promptVersion,
                            ],
                        ]);

                        if (!$okSpend) {
                            // Extremely rare (race). Force balance to 0 to keep hard-block semantics.
                            $creditsRepo->setBalance(0.0, [
                                'user_id' => (int) get_current_user_id(),
                                'purpose' => 'race_debit',
                                'object_type' => 'system',
                                'note' => 'credits_race_force_zero',
                                'meta' => [
                                    'attempted_debit' => $creditsUsed,
                                ],
                            ]);
                            $creditsWarning = __('Credits were depleted during processing (race condition). Balance forced to 0.', 'wp-seo-next');
                        }
                    }

                    $creditsRemaining = $creditsRepo->getBalance();
                }

                // Update daily usage.
                $this->usage->addUsage(1, $tokensIn, $tokensOut, $tokensTotal, $costUsd);
                $usageNow = $this->usage->getToday();

                return rest_ensure_response([
                    'provider' => $provider,
                    'field' => $field,
                    'text' => $text,
                    'usage' => [
                        'tokens_in' => $tokensIn,
                        'tokens_out' => $tokensOut,
                        'tokens_total' => $tokensTotal,
                        'cost_usd' => $costUsd,
                        'today' => $usageNow,
                    ],
                    'credits' => [
                        'enabled' => $localCreditsEnabled,
                        'mode' => $localCreditsEnabled ? $creditsRepo->mode() : '',
                        'balance' => $creditsRemaining,
                        'warning' => $creditsWarning,
                    ],
                    'saas' => [
                        'credits_cached' => (new SaaSAICreditsRepository())->get(),
                    ],
                    'prompt_version' => $promptVersion,
                ]);
            },
            'args'                => [
                'post_id' => ['type' => 'integer', 'required' => true],
                'field'   => ['type' => 'string', 'required' => true],
                'title'   => ['type' => 'string', 'required' => false],
                'content' => ['type' => 'string', 'required' => false],
                'excerpt' => ['type' => 'string', 'required' => false],
                'meta'    => ['type' => 'object', 'required' => false],
            ],
        ]);
    }

    /**
     * @return array{title:string,content:string,excerpt:string}
     */
    private function getPostDataFromRequest(int $postId, \WP_REST_Request $request): array
    {
        $extractor = new ContentExtractor();

        $title = $request->get_param('title');
        $content = $request->get_param('content');
        $excerpt = $request->get_param('excerpt');

        if (!is_string($title) || $title === '') {
            $title = (string) get_the_title($postId);
        }

        if (!is_string($content) || $content === '') {
            $content = $extractor->getPostHtml($postId, ['context' => 'ai']);
        }

        if (!is_string($excerpt) || $excerpt === '') {
            $excerpt = $extractor->getPostExcerptText($postId, 28);
        }

        return [
            'title' => $title,
            'content' => $content,
            'excerpt' => $excerpt,
        ];
    }

    /**
     * @param array<string, string> $vars
     */
    private function renderPrompt(string $template, array $vars): string
    {
        $out = $template;
        foreach ($vars as $k => $v) {
            $out = str_replace($k, $v, $out);
        }
        return $out;
    }

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

    /**
     * @param array{title:string,content:string,excerpt:string} $data
     * @param array<string,mixed> $meta
     * @return array<string,mixed>
     */
    private function buildStructuredGenerateRequest(
        int $postId,
        string $field,
        array $data,
        array $meta,
        string $plainContent,
        string $plainExcerpt,
        string $siteName,
        string $locale,
        int $maxLen,
        string $currentSeoTitle,
        string $currentDesc,
        string $prompt,
        string $promptKey,
        int $promptVersion,
        string $promptHash
    ): array {
        $focusKeyword = $this->metaScalarText($meta, ['current_focus_keyword', 'focus_keyword', 'primary_keyword']);
        if ($focusKeyword === '' && function_exists('get_post_meta')) {
            $focusKeyword = trim((string) get_post_meta($postId, MetaKeys::FOCUS_KEYWORD, true));
        }

        $secondaryKeywords = $this->metaStringList($meta, ['secondary_keywords', 'keywords', 'additional_keywords']);
        $negativeKeywords = $this->metaStringList($meta, ['negative_keywords', 'excluded_keywords']);
        $mustInclude = $this->metaStringList($meta, ['must_include', 'include_terms', 'required_terms']);
        $mustAvoid = $this->metaStringList($meta, ['must_avoid', 'avoid_terms', 'blocked_terms']);
        $competitorUrls = $this->metaStringList($meta, ['competitor_urls', 'competitors', 'comparison_urls']);

        $keywordsHint = $this->metaScalarText($meta, ['keywords_hint', 'keyword_hints']);
        if ($keywordsHint === '' && !empty($secondaryKeywords)) {
            $keywordsHint = implode(', ', array_slice($secondaryKeywords, 0, 8));
        }

        $constraints = [
            'max_length' => max(20, $maxLen),
        ];
        if ($field === 'title') {
            $constraints['title_max'] = max(20, $maxLen);
        } else {
            $constraints['description_max'] = max(40, $maxLen);
        }

        $request = [
            'purpose' => 'editor_' . sanitize_key($field),
            'task' => [
                'type' => 'meta_field',
                'field' => sanitize_key($field),
                'operation' => 'generate',
            ],
            'desired_output' => [
                'field' => sanitize_key($field),
                'format' => 'text',
            ],
            'inputs' => [
                'primary_keyword' => $focusKeyword,
                'secondary_keywords' => $secondaryKeywords,
                'negative_keywords' => $negativeKeywords,
                'intent' => $this->metaScalarText($meta, ['intent', 'search_intent']),
                'location' => $this->metaScalarText($meta, ['location', 'geo', 'city', 'service_area']),
                'tone' => $this->metaScalarText($meta, ['tone', 'voice']),
                'must_include' => $mustInclude,
                'must_avoid' => $mustAvoid,
                'competitor_urls' => $competitorUrls,
            ],
            'context' => [
                'post_id' => $postId,
                'post_type' => function_exists('get_post_type') ? (string) get_post_type($postId) : '',
                'post_title' => (string) $data['title'],
                'excerpt' => $plainExcerpt,
                'content' => $plainContent,
                'site_name' => $siteName,
                'site_locale' => $locale,
                'current_seo_title' => $currentSeoTitle,
                'current_meta_description' => $currentDesc,
                'current_focus_keyword' => $focusKeyword,
                'keywords_hint' => $keywordsHint,
            ],
            'constraints' => $constraints,
            'prompt' => [
                'text' => $prompt,
                'key' => $promptKey,
                'version' => $promptVersion,
                'hash' => $promptHash,
            ],
            'legacy_prompt' => $prompt,
        ];

        return $request;
    }

    /**
     * @param array<string,mixed> $meta
     * @param string[] $keys
     */
    private function metaScalarText(array $meta, array $keys): string
    {
        foreach ($keys as $key) {
            if (!array_key_exists($key, $meta)) {
                continue;
            }
            $value = $meta[$key];
            if (is_scalar($value)) {
                $text = trim((string) $value);
                if ($text !== '') {
                    return $text;
                }
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $meta
     * @param string[] $keys
     * @return string[]
     */
    private function metaStringList(array $meta, array $keys): array
    {
        $items = [];
        foreach ($keys as $key) {
            if (!array_key_exists($key, $meta)) {
                continue;
            }
            $value = $meta[$key];
            if (is_array($value)) {
                foreach ($value as $item) {
                    if (!is_scalar($item)) {
                        continue;
                    }
                    $text = trim((string) $item);
                    if ($text !== '') {
                        $items[] = $text;
                    }
                }
                continue;
            }

            if (is_scalar($value)) {
                $text = trim((string) $value);
                if ($text === '') {
                    continue;
                }
                $parts = preg_split('/[
,;]+/u', $text);
                if (!is_array($parts)) {
                    $parts = [$text];
                }
                foreach ($parts as $part) {
                    $part = trim((string) $part);
                    if ($part !== '') {
                        $items[] = $part;
                    }
                }
            }
        }

        $items = array_values(array_unique(array_filter($items, static function ($item) {
            return is_string($item) && trim($item) !== '';
        })));

        return array_slice($items, 0, 20);
    }

    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 truncateChars(string $text, int $maxChars): string
    {
        $text = (string) $text;
        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;
    }
}
