<?php

namespace WPSN\SEO;

use WPSN\Core\Capabilities;
use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Modules\AI\AICallRepository;
use WPSN\Modules\AI\AISecretsRepository;
use WPSN\Modules\AI\AIUsageRepository;
use WPSN\Modules\AI\OpenAIClient;
use WPSN\Modules\AI\Redactor;
use WPSN\Modules\AI\SaaSAIProxyClient;
use WPSN\Modules\SaaSSync\SaaSSyncService;

defined('ABSPATH') || exit;

/**
 * Manual AI helper for per-post custom JSON-LD drafts.
 *
 * This intentionally does NOT replace the default schema graph. It only creates
 * extra custom JSON-LD nodes (FAQ/HowTo/Event/Recipe/Review/Product/ItemList/Service/etc.) that
 * the editor can review before saving.
 */
final class SchemaAiGenerator
{
    private Settings $settings;
    private Logger $logger;
    private AISecretsRepository $secrets;
    private AIUsageRepository $usage;
    private AICallRepository $calls;

    public function __construct(Settings $settings, Logger $logger)
    {
        $this->settings = $settings;
        $this->logger = $logger;
        $this->secrets = new AISecretsRepository();
        $this->usage = new AIUsageRepository();
        $this->calls = new AICallRepository();
    }

    /**
     * @return array{allowed:bool,provider:string,reason:string}
     */
    public function availabilityForPost(int $postId): array
    {
        if ($postId <= 0) {
            return ['allowed' => false, 'provider' => '', 'reason' => __('Invalid post.', 'wp-seo-next')];
        }

        if ((bool) $this->settings->get('privacy.local_only', false)) {
            return ['allowed' => false, 'provider' => '', 'reason' => __('External services are disabled by Local-only mode.', 'wp-seo-next')];
        }

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

        if (!Capabilities::canUseAI()) {
            return ['allowed' => false, 'provider' => '', 'reason' => __('You are not allowed to use external AI on this site.', 'wp-seo-next')];
        }

        $post = get_post($postId);
        if (!$post instanceof \WP_Post) {
            return ['allowed' => false, 'provider' => '', 'reason' => __('Post not found.', 'wp-seo-next')];
        }

        if ((bool) $this->settings->get('ai.privacy.no_drafts', true)) {
            $status = isset($post->post_status) && is_string($post->post_status) ? (string) $post->post_status : 'publish';
            if (!in_array($status, ['publish', 'private'], true)) {
                return ['allowed' => false, 'provider' => '', 'reason' => __('AI generation is blocked for drafts by privacy setting.', 'wp-seo-next')];
            }
        }

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

        if ($provider === 'openai' && !$this->secrets->hasOpenAIKey()) {
            return ['allowed' => false, 'provider' => '', 'reason' => __('OpenAI API key is not configured.', 'wp-seo-next')];
        }

        if ($provider === 'saas_proxy') {
            $saas = new SaaSSyncService($this->settings, $this->logger);
            if (!$saas->isEnabled() || !$saas->isActive() || !$saas->isTrusted()) {
                return ['allowed' => false, 'provider' => '', 'reason' => __('SaaS AI proxy is not active/trusted yet.', 'wp-seo-next')];
            }
        }

        return ['allowed' => true, 'provider' => $provider, 'reason' => ''];
    }

    /**
     * @return array{ok:bool,json_ld?:string,error?:string,provider?:string,preset_slug?:string,usage?:array<string,int>,latency_ms?:int}
     */
    public function generateForPost(int $postId, string $presetSlug = 'auto'): array
    {
        $availability = $this->availabilityForPost($postId);
        if (empty($availability['allowed'])) {
            return ['ok' => false, 'error' => (string) ($availability['reason'] ?? __('Schema AI is not available.', 'wp-seo-next'))];
        }

        $provider = (string) $availability['provider'];
        $presetSlug = sanitize_key($presetSlug);
        if ($presetSlug === '') {
            $presetSlug = 'auto';
        }

        $prompt = $this->buildPrompt($postId, $presetSlug);
        if ($prompt === '') {
            return ['ok' => false, 'error' => __('Could not build an AI prompt for this post.', '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', 650);
        $maxOutput = max(180, min(2400, $maxOutput));
        $temperature = (float) $this->settings->get('ai.external.temperature', 0.2);
        $temperature = max(0.0, min(1.0, $temperature));

        $resp = $this->callProvider($provider, $model, $prompt, $maxOutput, $temperature, $postId, $presetSlug);
        if (empty($resp['ok'])) {
            $this->logCall($provider, $model, $postId, false, $resp, 'schema_jsonld');
            return [
                'ok' => false,
                'error' => isset($resp['error']) ? (string) $resp['error'] : __('Schema AI request failed.', 'wp-seo-next'),
                'provider' => $provider,
            ];
        }

        $raw = $this->extractJson((string) ($resp['text'] ?? ''));
        $raw = SchemaCustomJsonLdResolver::sanitizeRaw($raw);
        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            $this->logCall($provider, $model, $postId, false, $resp, 'schema_jsonld_invalid_json');
            return [
                'ok' => false,
                'error' => __('AI returned text, but it was not valid JSON-LD.', 'wp-seo-next'),
                'provider' => $provider,
            ];
        }

        $normalized = $this->normalizeToNodes($decoded);
        if (empty($normalized)) {
            $this->logCall($provider, $model, $postId, false, $resp, 'schema_jsonld_no_nodes');
            return [
                'ok' => false,
                'error' => __('AI returned JSON, but no JSON-LD nodes with @type were found.', 'wp-seo-next'),
                'provider' => $provider,
            ];
        }

        $pretty = wp_json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if (!is_string($pretty) || trim($pretty) === '') {
            $pretty = $raw;
        }

        $usage = isset($resp['usage']) && is_array($resp['usage']) ? $resp['usage'] : ['input' => 0, 'output' => 0, 'total' => 0];
        $tokensIn = isset($usage['input']) ? (int) $usage['input'] : 0;
        $tokensOut = isset($usage['output']) ? (int) $usage['output'] : 0;
        $tokensTotal = isset($usage['total']) ? (int) $usage['total'] : ($tokensIn + $tokensOut);
        $costUsd = $this->estimateCostUsd($tokensIn, $tokensOut);
        $this->usage->addUsage(1, $tokensIn, $tokensOut, $tokensTotal, $costUsd);

        $resp['cost_usd'] = $costUsd;
        $resp['response_text'] = $pretty;
        $this->logCall($provider, $model, $postId, true, $resp, 'schema_jsonld');

        return [
            'ok' => true,
            'json_ld' => $pretty,
            'provider' => $provider,
            'preset_slug' => $this->detectPresetSlug($normalized, $presetSlug),
            'usage' => [
                'input' => $tokensIn,
                'output' => $tokensOut,
                'total' => $tokensTotal,
            ],
            'latency_ms' => isset($resp['latency_ms']) ? (int) $resp['latency_ms'] : 0,
        ];
    }

    private function buildPrompt(int $postId, string $presetSlug): string
    {
        $post = get_post($postId);
        if (!$post instanceof \WP_Post) {
            return '';
        }

        $title = trim((string) get_the_title($postId));
        $excerpt = '';
        if (isset($post->post_excerpt) && is_string($post->post_excerpt) && trim($post->post_excerpt) !== '') {
            $excerpt = trim((string) $post->post_excerpt);
        } else {
            $excerpt = trim((string) get_the_excerpt($postId));
        }
        $contentRaw = isset($post->post_content) && is_string($post->post_content) ? (string) $post->post_content : '';
        $content = $this->plainText($contentRaw);
        $content = $this->truncateChars($content, 10000);

        $mappingHelper = new SchemaMappingHelper();
        $analysis = $mappingHelper->analyzePost($postId);
        $analysisSummary = $this->formatAnalysisSummary($analysis);
        $localDraft = $this->localDraftForPrompt($mappingHelper, $postId, $presetSlug, $analysis);

        $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        $blockSummary = isset($analysis['block_summary']) && is_array($analysis['block_summary']) ? $analysis['block_summary'] : [];
        $pageSignals = strtolower($title . ' ' . $excerpt . ' ' . $content);
        $isWooProductPage = isset($post->post_type) && (string) $post->post_type === 'product';
        $hasProductSignals = !empty($productData)
            || count($comparisonItems) >= 1
            || (!empty($blockSummary['product_blocks']) && (int) $blockSummary['product_blocks'] > 0)
            || preg_match('/\b(best|top|comparison|compare|versus|vs\.?|review|pricing|deal|offer)\b/i', $pageSignals) === 1;
        $hasComparisonSignals = count($comparisonItems) >= 2
            || (!empty($blockSummary['comparison_products']) && (int) $blockSummary['comparison_products'] >= 2)
            || preg_match('/\b(best|top|comparison|compare|versus|vs\.?|alternatives?)\b/i', $pageSignals) === 1;

        $allowedTypes = ['FAQPage', 'HowTo', 'Event', 'Recipe', 'Review', 'Service'];
        if (!$isWooProductPage && ($hasProductSignals || $presetSlug === 'product' || $presetSlug === 'comparison')) {
            $allowedTypes[] = 'Product';
        }
        if ($hasComparisonSignals || $presetSlug === 'comparison') {
            $allowedTypes[] = 'ItemList';
        }

        $presetInstruction = __('Choose the most appropriate schema type automatically.', 'wp-seo-next');
        if ($presetSlug !== 'auto') {
            $map = [
                'faq' => 'FAQPage',
                'howto' => 'HowTo',
                'event' => 'Event',
                'recipe' => 'Recipe',
                'review' => 'Review',
                'product' => 'Product',
                'comparison' => 'ItemList',
                'service' => 'Service',
            ];
            if (isset($map[$presetSlug])) {
                $presetInstruction = sprintf(__('Prefer %s unless the content clearly contradicts it.', 'wp-seo-next'), $map[$presetSlug]);
            }
        }

        $coreNodeInstruction = "Do NOT output Organization, WebSite, BreadcrumbList, BlogPosting, or other site-wide/core nodes because the plugin already handles those.
";
        if ($isWooProductPage) {
            $coreNodeInstruction .= "Do NOT output Product for this page because the plugin's core WooCommerce product graph already handles it.
";
        } elseif (in_array('Product', $allowedTypes, true)) {
            $coreNodeInstruction .= "Product is allowed only for non-Woo affiliate/product pages or comparison pages when the product data is clearly visible in the content/blocks.
";
        } else {
            $coreNodeInstruction .= "Do NOT output Product unless the page clearly contains standalone non-Woo product data or a comparison list.
";
        }
        if (in_array('ItemList', $allowedTypes, true)) {
            $coreNodeInstruction .= "ItemList is allowed for comparison, roundup, alternatives, or best-of pages with multiple products. Preserve visible ranking order, comparison-table positions, seller/offer nuances, matrix-table feature rows, and card-plus-table merged product details when the content clearly shows them.
";
        }

        $prompt = "You are creating custom JSON-LD for a WordPress SEO plugin.
"
            . "Return ONLY valid JSON. No markdown fences. No commentary.
"
            . $coreNodeInstruction
            . "Use only extra schema that belongs specifically to this page.
"
            . "Allowed primary types: " . implode(', ', $allowedTypes) . ".
"
            . $presetInstruction . "
"
            . "If data is not clearly visible in the input, omit the property instead of inventing it.
"
            . "Prefer the structured block signals below over weak prose heuristics when they exist.
"
            . "If the content does not support any of those types, return an empty array [] unless a small extra node clearly adds value.
"
            . "Use a single node object when one type fits. Use an array only when multiple extra nodes are genuinely present.

"
            . "Title: " . $title . "
"
            . "Excerpt: " . $excerpt . "
"
            . "Permalink: " . (string) get_permalink($post) . "
"
            . ($analysisSummary !== '' ? "Block analysis:
" . $analysisSummary . "
" : '')
            . ($localDraft !== '' ? "Local starter draft (best-effort, may be partial):
" . $localDraft . "
" : '')
            . "Content:
" . $content;

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

        return $prompt;
    }

    /**
     * @param array<string,mixed> $analysis
     */
    private function formatAnalysisSummary(array $analysis): string
    {
        $lines = [];

        $blockSummary = isset($analysis['block_summary']) && is_array($analysis['block_summary']) ? $analysis['block_summary'] : [];
        if (!empty($blockSummary['faq_pairs'])) {
            $lines[] = '- faq_pairs_detected: ' . (int) $blockSummary['faq_pairs'];
        }
        if (!empty($blockSummary['howto_steps'])) {
            $lines[] = '- howto_steps_detected: ' . (int) $blockSummary['howto_steps'];
        }
        if (!empty($blockSummary['recipe_ingredients'])) {
            $lines[] = '- recipe_ingredients_detected: ' . (int) $blockSummary['recipe_ingredients'];
        }
        if (!empty($blockSummary['recipe_instructions'])) {
            $lines[] = '- recipe_instructions_detected: ' . (int) $blockSummary['recipe_instructions'];
        }
        if (!empty($blockSummary['event_blocks'])) {
            $lines[] = '- event_blocks_detected: ' . (int) $blockSummary['event_blocks'];
        }
        if (!empty($blockSummary['review_blocks'])) {
            $lines[] = '- review_blocks_detected: ' . (int) $blockSummary['review_blocks'];
        }
        if (!empty($blockSummary['product_blocks'])) {
            $lines[] = '- product_blocks_detected: ' . (int) $blockSummary['product_blocks'];
        }
        if (!empty($blockSummary['service_blocks'])) {
            $lines[] = '- service_blocks_detected: ' . (int) $blockSummary['service_blocks'];
        }

        $blockMapMatches = isset($analysis['block_map_matches']) && is_array($analysis['block_map_matches']) ? $analysis['block_map_matches'] : [];
        if (!empty($blockMapMatches)) {
            $recognized = [];
            foreach (array_slice($blockMapMatches, 0, 6) as $match) {
                if (!is_array($match)) {
                    continue;
                }
                $label = isset($match['label']) ? trim((string) $match['label']) : '';
                $types = isset($match['schema_types']) && is_array($match['schema_types']) ? implode('/', array_map('strval', $match['schema_types'])) : '';
                if ($label !== '' && $types !== '') {
                    $recognized[] = $label . ' => ' . $types;
                }
            }
            if (!empty($recognized)) {
                $lines[] = '- recognized_block_map: ' . implode(' | ', $recognized);
            }
        }

        $faqPairs = isset($analysis['faq_pairs']) && is_array($analysis['faq_pairs']) ? $analysis['faq_pairs'] : [];
        if (!empty($faqPairs)) {
            $examples = [];
            foreach (array_slice($faqPairs, 0, 3) as $pair) {
                if (!is_array($pair)) {
                    continue;
                }
                $q = isset($pair['question']) ? trim((string) $pair['question']) : '';
                $a = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
                if ($q !== '' && $a !== '') {
                    $examples[] = $q . ' => ' . $this->truncateChars($a, 160);
                }
            }
            if (!empty($examples)) {
                $lines[] = '- faq_examples: ' . implode(' | ', $examples);
            }
        }

        $steps = isset($analysis['howto_steps']) && is_array($analysis['howto_steps']) ? $analysis['howto_steps'] : [];
        if (!empty($steps)) {
            $lines[] = '- howto_step_examples: ' . implode(' | ', array_map(function ($value): string {
                return $this->truncateChars((string) $value, 120);
            }, array_slice($steps, 0, 4)));
        }

        $ingredients = isset($analysis['recipe_ingredients']) && is_array($analysis['recipe_ingredients']) ? $analysis['recipe_ingredients'] : [];
        if (!empty($ingredients)) {
            $lines[] = '- recipe_ingredient_examples: ' . implode(' | ', array_map(function ($value): string {
                return $this->truncateChars((string) $value, 80);
            }, array_slice($ingredients, 0, 6)));
        }

        $instructions = isset($analysis['recipe_instructions']) && is_array($analysis['recipe_instructions']) ? $analysis['recipe_instructions'] : [];
        if (!empty($instructions)) {
            $lines[] = '- recipe_instruction_examples: ' . implode(' | ', array_map(function ($value): string {
                return $this->truncateChars((string) $value, 120);
            }, array_slice($instructions, 0, 4)));
        }

        $eventData = isset($analysis['event_data']) && is_array($analysis['event_data']) ? $analysis['event_data'] : [];
        if (!empty($eventData)) {
            $lines[] = '- event_fields: ' . wp_json_encode($eventData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $reviewData = isset($analysis['review_data']) && is_array($analysis['review_data']) ? $analysis['review_data'] : [];
        if (!empty($reviewData)) {
            $lines[] = '- review_fields: ' . wp_json_encode($reviewData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
        if (!empty($productData)) {
            $lines[] = '- product_fields: ' . wp_json_encode($productData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        if (!empty($comparisonItems)) {
            $lines[] = '- comparison_items_detected: ' . count($comparisonItems);
            $comparisonMeta = isset($analysis['comparison_meta']) && is_array($analysis['comparison_meta']) ? $analysis['comparison_meta'] : [];
            if (!empty($comparisonMeta)) {
                $lines[] = '- comparison_meta: ' . wp_json_encode($comparisonMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                if (!empty($comparisonMeta['generic_html_tables'])) {
                    $lines[] = '- generic_html_tables: ' . (int) $comparisonMeta['generic_html_tables'];
                }
                if (!empty($comparisonMeta['vendor_table_layouts'])) {
                    $lines[] = '- vendor_table_layouts: ' . (int) $comparisonMeta['vendor_table_layouts'];
                }
                if (!empty($comparisonMeta['vendor_widget_layouts'])) {
                    $lines[] = '- vendor_widget_layouts: ' . (int) $comparisonMeta['vendor_widget_layouts'];
                }
                if (!empty($comparisonMeta['vendor_shortcodes'])) {
                    $lines[] = '- vendor_shortcodes: ' . (int) $comparisonMeta['vendor_shortcodes'];
                }
                if (!empty($comparisonMeta['vendors_detected']) && is_array($comparisonMeta['vendors_detected'])) {
                    $lines[] = '- comparison_vendors: ' . implode(', ', array_map('strval', array_slice($comparisonMeta['vendors_detected'], 0, 5)));
                }
            }
            $preview = [];
            foreach (array_slice($comparisonItems, 0, 5) as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $name = isset($item['name']) ? trim((string) $item['name']) : '';
                $price = isset($item['price']) ? trim((string) $item['price']) : '';
                $currency = isset($item['currency']) ? trim((string) $item['currency']) : '';
                $position = isset($item['position']) ? trim((string) $item['position']) : '';
                $seller = isset($item['seller']) ? trim((string) $item['seller']) : '';
                $rankLabel = isset($item['rank_label']) ? trim((string) $item['rank_label']) : '';
                if ($name === '') {
                    continue;
                }
                $parts = [];
                if ($position !== '') {
                    $parts[] = '#' . $position;
                }
                if ($rankLabel !== '') {
                    $parts[] = $rankLabel;
                }
                if ($price !== '') {
                    $parts[] = trim($price . ' ' . $currency);
                }
                if ($seller !== '') {
                    $parts[] = 'seller: ' . $seller;
                }
                $preview[] = $name . (!empty($parts) ? ' (' . implode('; ', $parts) . ')' : '');
            }
            if (!empty($preview)) {
                $lines[] = '- comparison_item_examples: ' . implode(' | ', $preview);
            }
        }

        $serviceData = isset($analysis['service_data']) && is_array($analysis['service_data']) ? $analysis['service_data'] : [];
        if (!empty($serviceData)) {
            $lines[] = '- service_fields: ' . wp_json_encode($serviceData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        return implode("\n", $lines);
    }

    /**
     * @param array<string,mixed> $analysis
     */
    private function localDraftForPrompt(SchemaMappingHelper $helper, int $postId, string $presetSlug, array $analysis): string
    {
        $candidate = $presetSlug !== 'auto' ? sanitize_key($presetSlug) : '';
        if ($candidate === '') {
            $available = isset($analysis['available_autofill']) && is_array($analysis['available_autofill']) ? $analysis['available_autofill'] : [];
            if (!empty($available[0]) && is_scalar($available[0])) {
                $candidate = sanitize_key((string) $available[0]);
            }
        }
        if ($candidate === '') {
            return '';
        }

        $draft = $helper->buildMappedTemplate($postId, $candidate);
        if ($draft === '') {
            return '';
        }

        return $this->truncateChars($draft, 4000);
    }

    /**
     * @return array{ok:bool,text:string,usage:array{input:int,output:int,total:int},latency_ms:int,error:string,http_status:int,retry_after:int,cost_usd?:float,response_text?:string}
     */
    private function callProvider(string $provider, string $model, string $prompt, int $maxOutput, float $temperature, int $postId, string $presetSlug): array
    {
        if ($provider === 'saas_proxy') {
            $saas = new SaaSSyncService($this->settings, $this->logger);
            $client = new SaaSAIProxyClient($saas, $this->logger, $this->settings);
            return $client->generateText($model, $prompt, $maxOutput, $temperature, 'schema_jsonld', [
                'post_id' => $postId,
                'preset' => $presetSlug,
            ]);
        }

        $client = new OpenAIClient(
            $this->secrets->getOpenAIKey(),
            (string) $this->settings->get('ai.external.openai_org', ''),
            (string) $this->settings->get('ai.external.openai_project', ''),
            $this->logger
        );

        return $client->generateText($model, $prompt, $maxOutput, $temperature);
    }

    private function extractJson(string $text): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        if (preg_match('/```(?:json)?\s*(.*?)```/is', $text, $m)) {
            $text = trim((string) ($m[1] ?? ''));
        }

        $startObject = strpos($text, '{');
        $startArray = strpos($text, '[');
        $start = false;
        if ($startObject !== false && $startArray !== false) {
            $start = min($startObject, $startArray);
        } elseif ($startObject !== false) {
            $start = $startObject;
        } elseif ($startArray !== false) {
            $start = $startArray;
        }

        if ($start !== false && $start > 0) {
            $text = substr($text, $start);
        }

        // Trim trailing prose after the last closing brace/bracket.
        $lastBrace = strrpos($text, '}');
        $lastBracket = strrpos($text, ']');
        $end = false;
        if ($lastBrace !== false && $lastBracket !== false) {
            $end = max($lastBrace, $lastBracket);
        } elseif ($lastBrace !== false) {
            $end = $lastBrace;
        } elseif ($lastBracket !== false) {
            $end = $lastBracket;
        }
        if ($end !== false) {
            $text = substr($text, 0, $end + 1);
        }

        return trim($text);
    }

    /**
     * @param array<mixed> $decoded
     * @return array<int,array<string,mixed>>
     */
    private function normalizeToNodes(array $decoded): array
    {
        if ($this->isList($decoded)) {
            $nodes = [];
            foreach ($decoded as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $nodes = array_merge($nodes, $this->normalizeObjectOrGraph($item));
            }
            return $nodes;
        }

        return $this->normalizeObjectOrGraph($decoded);
    }

    /**
     * @param array<string,mixed> $node
     * @return array<int,array<string,mixed>>
     */
    private function normalizeObjectOrGraph(array $node): array
    {
        if (isset($node['@graph']) && is_array($node['@graph'])) {
            $nodes = [];
            foreach ($node['@graph'] as $child) {
                if (is_array($child) && !empty($child['@type'])) {
                    $nodes[] = $child;
                }
            }
            return $nodes;
        }

        if (!empty($node['@type'])) {
            return [$node];
        }

        return [];
    }

    /**
     * @param array<int,array<string,mixed>> $nodes
     */
    private function detectPresetSlug(array $nodes, string $requested): string
    {
        $requested = sanitize_key($requested);
        if ($requested !== '' && $requested !== 'auto') {
            return $requested;
        }

        $map = [
            'FAQPage' => 'faq',
            'HowTo' => 'howto',
            'Event' => 'event',
            'Recipe' => 'recipe',
            'Review' => 'review',
            'Product' => 'product',
            'ItemList' => 'comparison',
            'Service' => 'service',
        ];

        foreach ($nodes as $node) {
            $type = isset($node['@type']) ? trim((string) $node['@type']) : '';
            if ($type !== '' && isset($map[$type])) {
                return $map[$type];
            }
        }

        return 'auto';
    }

    /**
     * @param array<string,mixed> $resp
     */
    private function logCall(string $provider, string $model, int $postId, bool $success, array $resp, string $purpose): void
    {
        $usage = isset($resp['usage']) && is_array($resp['usage']) ? $resp['usage'] : [];
        $this->calls->insert([
            'provider' => $provider,
            'model' => $model,
            'purpose' => $purpose,
            'post_id' => $postId,
            'prompt_version' => 1,
            'tokens_in' => isset($usage['input']) ? (int) $usage['input'] : 0,
            'tokens_out' => isset($usage['output']) ? (int) $usage['output'] : 0,
            'tokens_total' => isset($usage['total']) ? (int) $usage['total'] : 0,
            'cost_usd' => isset($resp['cost_usd']) ? (float) $resp['cost_usd'] : 0.0,
            'latency_ms' => isset($resp['latency_ms']) ? (int) $resp['latency_ms'] : 0,
            'success' => $success,
            'error_message' => isset($resp['error']) ? (string) $resp['error'] : '',
            'response_text' => isset($resp['response_text']) ? (string) $resp['response_text'] : (string) ($resp['text'] ?? ''),
        ]);
    }

    private function estimateCostUsd(int $tokensIn, int $tokensOut): float
    {
        $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);
        if ($priceIn <= 0.0 && $priceOut <= 0.0) {
            return 0.0;
        }

        return (($tokensIn / 1000000) * $priceIn) + (($tokensOut / 1000000) * $priceOut);
    }

    /**
     * @param array<mixed> $array
     */
    private function isList(array $array): bool
    {
        if (function_exists('array_is_list')) {
            return array_is_list($array);
        }

        $i = 0;
        foreach (array_keys($array) as $key) {
            if ($key !== $i) {
                return false;
            }
            $i++;
        }

        return true;
    }

    private function plainText(string $value): string
    {
        $value = str_replace(['</p>', '</li>', '<br>', '<br/>', '<br />'], "\n", $value);
        $value = wp_strip_all_tags($value);
        $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $value = preg_replace('/[\t ]+/u', ' ', $value) ?? $value;
        $value = preg_replace('/\n{3,}/u', "\n\n", $value) ?? $value;
        return trim($value);
    }

    private function truncateChars(string $text, int $maxChars): string
    {
        if ($maxChars <= 0) {
            return $text;
        }

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