<?php
/**
 * DBA OpenAI - Content generation via OpenAI API
 * Handles: article generation, SEO, image prompts
 */
class DBA_OpenAI {

    private static $instance = null;
    private $api_key;
    private $model;
    private $api_url = 'https://api.openai.com/v1/chat/completions';

    // Approved themes (must choose 1 per post)
    private $themes = array(
        'Artificial Intelligence',
        'Data Centers & Digital Infrastructure',
        'Cybersecurity',
        'Healthcare Technology',
        'Fintech',
        'Power & Energy Infrastructure',
        'Semiconductors',
        'Pharmaceuticals & Biotech',
        'Private Credit & Asset-Based Financing',
        'Real Estate (Industrial, Residential, Data Centers)',
        'Professional Services (Law Firms, Consultancies)',
        'Renewable Energy',
        'Senior Living',
        'Petcare',
        'Insurance',
        'Rare Minerals',
        'Software/SaaS',
    );

    // Subject definitions
    /**
     * Subject list is loaded from admin settings (free text, any topic).
     * No hardcoded subjects.
     */
    public function get_subjects() {
        $content_settings = get_option('dba_content_settings', array());
        return isset($content_settings['subjects']) ? $content_settings['subjects'] : array('Luxembourg holding company');
    }

    public static function get_instance() {
        if (null === self::$instance) self::$instance = new self();
        return self::$instance;
    }

    private function __construct() {
        $settings = get_option('dba_settings', array());
        $this->api_key = isset($settings['openai_api_key']) ? $settings['openai_api_key'] : '';
        $this->model = isset($settings['openai_model']) ? $settings['openai_model'] : 'gpt-4.1';
    }

    public function get_themes() {
        return $this->themes;
    }

    /**
     * Pick N distinct themes not used today
     */
    public function pick_themes($count = 5, $used_today = array()) {
        $available = array_diff($this->themes, $used_today);
        if (count($available) < $count) {
            $available = $this->themes; // Reset if exhausted
        }
        $keys = array_rand(array_values($available), min($count, count($available)));
        if (!is_array($keys)) $keys = array($keys);

        $available = array_values($available);
        $picked = array();
        foreach ($keys as $k) {
            $picked[] = $available[$k];
        }
        return $picked;
    }

    /**
     * Build system prompt — adapts to any subject
     */
    private function build_system_prompt($subject = '') {
        if (empty($subject)) $subject = 'Luxembourg holding company';

        // Detect if subject is Luxembourg/legal/tax related
        $lux_keywords = array('luxembourg', 'soparfi', 'spf', 'holding', 'tax', 'structur', 'fund setup', 'sicav', 'raif', 'wealth management', 'family office', 'corporate formation');
        $is_lux_legal = false;
        $subject_lower = strtolower($subject);
        foreach ($lux_keywords as $kw) {
            if (strpos($subject_lower, $kw) !== false) {
                $is_lux_legal = true;
                break;
            }
        }

        // Adaptive opener — always Damalion, always professional, but domain matches the subject
        if ($is_lux_legal) {
            $prompt = "You are a professional writer producing human-sounding legal and tax journalism for Damalion's blog (Luxembourg structuring).\n\n";
        } else {
            $prompt = "You are a professional writer producing human-sounding expert content for Damalion's blog. Damalion is an international consulting firm (https://www.damalion.com).\n\n";
        }

        $prompt .= "SUBJECT: {$subject}\n";
        $prompt .= "Write specifically and deeply about this subject. The subject drives the article — stay focused on it.\n\n";

        $prompt .= "TONE & STYLE — THIS IS CRITICAL:\n";
        $prompt .= "- Write like a real person talking to a smart client over coffee. Not like a textbook. Not like a robot.\n";
        $prompt .= "- Short sentences. Max 20-25 words per sentence. If a sentence feels long, split it.\n";
        $prompt .= "- Use everyday words. Say 'set up' not 'establish'. Say 'tax break' not 'fiscal incentive'. Say 'profits' not 'capital gains' (unless the exact legal term is needed).\n";
        $prompt .= "- Never say 'This section presents…', 'This article explores…', 'In this guide…'. Instead say 'We explain…', 'Damalion explains…', 'Damalion highlights…', or just get to the point.\n";
        $prompt .= "- Never start two paragraphs the same way.\n";
        $prompt .= "- Sound like a knowledgeable adviser who gets straight to the point.\n";
        $prompt .= "- 8th-grade reading level. A 14-year-old should understand the structure of the sentences (even if the topic is technical).\n\n";

        $prompt .= "DAMALION PROMOTION — CRITICAL RULE:\n";
        $prompt .= "- Damalion is an international consulting firm that provides professional services to entrepreneurs and investors.\n";
        $prompt .= "- NEVER promote 'the Damalion Blog' or say things like 'check out our blog', 'visit our blog', 'read more on our blog', 'For more on X, check out the Damalion Blog', etc.\n";
        $prompt .= "- Instead, promote Damalion's SERVICES: advisory, structuring, fund setup, company formation, cross-border support, etc.\n";
        $prompt .= "- Correct framing: 'Damalion helps investors structure cross-border deals', 'Damalion supports entrepreneurs with company formation', 'Contact Damalion for expert guidance'.\n";
        $prompt .= "- Wrong framing: 'For more on the business side of X, check out the Damalion Blog.' — this is BANNED.\n";
        $prompt .= "- Internal links should use natural anchor text about the topic, not 'our blog' or 'Damalion Blog'.\n\n";

        // CONDITIONAL: sector crossover only for Luxembourg/legal subjects
        if ($is_lux_legal) {
            $prompt .= "CONNECT THE SUBJECT TO ONE OF THESE SECTORS (pick the most relevant):\n";
            foreach ($this->themes as $t) {
                $prompt .= "- {$t}\n";
            }
            $prompt .= "\n";
        }

        $prompt .= "CRITICAL OUTPUT RULES:\n";
        $prompt .= "1) Output MUST be valid JSON only. No markdown. No explanations. No extra text.\n";
        $prompt .= "2) The JSON must match the provided schema exactly.\n";
        $prompt .= "3) content_html MUST end with the exact CTA_HTML. The CTA appears exactly once, as the last block.\n";
        $prompt .= "4) The article MUST include:\n";
        $prompt .= "   - a pillar link near the top (first or second paragraph), written naturally;\n";
        $prompt .= "   - at least 3 additional internal links woven naturally into the body paragraphs (INSIDE the text, as contextual anchor links — never as a standalone list or section at the bottom).\n";
        // Load banned words from settings (managed in admin UI)
        $validator = DBA_Validator::get_instance();
        $banned_list = $validator->get_banned_words();
        $banned_str = implode(', ', $banned_list);
        $prompt .= "5) Forbidden words (NEVER use in title or body): {$banned_str}.\n";
        $prompt .= "   If you use ANY of these words or phrases, the article WILL BE REJECTED by the validator.\n";
        $prompt .= "6b) BANNED URL: Never link to 'luxembourg-holding-companies-core-structuring-tool' — that page does not exist.\n";
        $prompt .= "6c) INTERNAL LINKS RULE: Only use the pillar URL and satellite URLs provided below. Do NOT invent or guess any damalion.com URLs. If you are unsure, omit the link.\n";

        // CONDITIONAL: geographic scope depends on subject
        if ($is_lux_legal) {
            $prompt .= "7) Include at least one real cross-border example with real countries (France, Spain, Germany, Italy, Benelux, Austria, Nordics, Poland, Czech Republic, Baltics). Use real city names. No 'Company A/B'.\n";
        } else {
            $prompt .= "7) Include at least one real-world example with real countries, real cities, real company names or real market data. No 'Company A/B'. The example must fit the subject naturally — do not force a Luxembourg or tax angle if the subject is about something else.\n";
        }

        $prompt .= "8) Use real numbers: market sizes, growth rates, deal values, timelines, percentages. Be specific.\n";
        $prompt .= "9) Each subtitle (<h2>, <h3>) MUST be followed by an intro sentence before any list or detail.\n";

        // CONDITIONAL: slug examples
        if ($is_lux_legal) {
            $prompt .= "10) Slug must use simple human words. Write like: 'luxembourg-holding-tax-benefits' or 'how-soparfi-works-for-investors'. No jargon in URLs.\n";
        } else {
            $prompt .= "10) Slug must use simple human words matching the subject. Write like: 'private-equity-trends-europe' or 'agrifood-investment-opportunities'. No jargon in URLs.\n";
        }

        $prompt .= "\nCONTENT STRUCTURE (mandatory):\n";
        $prompt .= "a) Do NOT include any 'Last updated' date line. Skip it entirely.\n";
        $prompt .= "b) When naming companies or institutions, wrap the name in an <a> link to their official website.\n";

        if ($is_lux_legal) {
            $prompt .= "c) When naming government authorities (e.g. Czech FMA, CSSF, BaFin), link to the official language version of their site.\n";
        } else {
            $prompt .= "c) When naming regulators, agencies, or organizations, link to their official website.\n";
        }

        $prompt .= "d) Include a visible FAQ section at the bottom (before CTA) with exactly 5 Q&As as:\n";
        $prompt .= "   <div class=\"faq-accordion\"><details><summary>Q: question?</summary><p>A: answer.</p></details></div>\n";
        $prompt .= "e) Include a Glossary section (after FAQ, before CTA) as accordion:\n";
        $prompt .= "   <div class=\"faq-accordion\"><details><summary>Term</summary><p>Definition.</p></details></div>\n";

        if ($is_lux_legal) {
            $prompt .= "f) Include a practical 'What advisers check' bulleted section with 4-8 clear steps.\n";
        } else {
            $prompt .= "f) Include a practical 'Key takeaways' or 'What to know' bulleted section with 4-8 clear points.\n";
        }

        $prompt .= "g) Write speakable_text: 2-3 sentence plain summary for voice assistants.\n";
        $prompt .= "h) Write howto_steps array from the practical steps.\n";
        $prompt .= "i) faqs array must match the visible FAQ section word-for-word.\n";
        $prompt .= "j) glossary array must match the visible glossary section word-for-word.\n";

        if ($is_lux_legal) {
            $prompt .= "11) No legal advice claims. Write informationally.\n";
        } else {
            $prompt .= "11) Write as expert analysis, not advice. Stay informational.\n";
        }

        return $prompt;
    }

    /**
     * Build hardener (second system/guardrail message)
     */
    private function build_hardener($has_news = false, $recent_patterns = array()) {
        $checks = "Before you output JSON, self-check:\n" .
               "- JSON parses\n" .
               "- CTA is last and appears once\n" .
               "- EVERY damalion.com URL in content_html matches EXACTLY one of the provided URLs (pillar_url or satellite_links). NO invented URLs.\n" .
               "- NEVER use URL containing 'core-structuring-tool' — that page does not exist.\n" .
               "- At least 3 satellite links are woven naturally into body paragraphs as contextual anchor links (NO standalone list or 'Related technical notes' section)\n" .
               "- Pillar link included near top using the exact pillar_url provided\n" .
               "- Forbidden words absent\n" .
               "- faqs array has exactly 5 items\n" .
               "- Each FAQ in content_html matches faqs array word-for-word\n" .
               "- glossary array has 5+ items\n" .
               "- howto_steps array has 4+ items\n" .
               "- speakable_text is 2-3 sentences, plain text\n" .
               "- Article body is at least 1200 words (excluding FAQ, glossary, CTA). If under 1200, add more depth, examples, and analysis.\n" .
               "- No 'Last updated' date line anywhere\n" .
               "- No subtitle followed immediately by a list (intro sentence required)\n" .
               "- Company/institution names are <a> links to official websites\n" .
               "- NO 'Related technical notes' section anywhere — all internal links must be INSIDE paragraphs\n" .
               "- ⚠️ BLOG PROMOTION CHECK: The article must NEVER contain phrases like 'check out the Damalion Blog', 'visit our blog', 'read more on our blog', or 'For more on X, check out the Damalion Blog'. Promote Damalion's SERVICES (advisory, structuring, company formation, fund setup), not the blog itself. If you find any blog-promotion phrase, remove it.\n";

        if ($has_news) {
            $checks .= "- ⚠️ NEWS CHECK: The title and first paragraph MUST be about the specific news story from the NEWS RESEARCH section. If your article is about a generic topic and ignores the news, REGENERATE. The news story is the REASON this article exists.\n";
        }

        if ( ! empty( $recent_patterns ) ) {
            $checks .= "- ⚠️ TITLE DIVERSITY CHECK: Your title's structural pattern must NOT match any of the recently used patterns listed in the prompt. If it does, rewrite the title with a completely different sentence structure.\n";
        }

        $checks .= "If any check fails, regenerate internally and output only the corrected JSON.";
        return $checks;
    }

    /**
     * Build user prompt for article generation
     */
    private function build_user_prompt($theme, $satellite_links, $cta_html, $pillar_url, $existing_slugs = array(), $subject = '', $research_context = '', $recent_patterns = array(), $is_url_strict = false) {
        $prompt = "Generate ONE satellite blog post for https://www.damalion.com/blog/ using the rules below.\n\n";

        // ── NEWS RESEARCH CONTEXT (if web search was performed) ──
        if (!empty($research_context)) {
            $prompt .= "╔══════════════════════════════════════════════════════════════╗\n";
            $prompt .= "║  ⚠️  MANDATORY: THIS ARTICLE MUST BE ABOUT THIS NEWS STORY  ║\n";
            $prompt .= "╚══════════════════════════════════════════════════════════════╝\n\n";
            $prompt .= "The following research was gathered from live web sources about a SPECIFIC news event.\n";
            $prompt .= "YOUR ARTICLE MUST BE ABOUT THIS NEWS STORY. This is not optional. This is not background material.\n";
            $prompt .= "- The title MUST reference the specific event, company, or topic from this research.\n";
            $prompt .= "- The opening paragraph MUST summarize the key news event described below.\n";
            $prompt .= "- Use specific facts, figures, dates, names, and quotes from this research — do NOT make up data.\n";
            $prompt .= "- Then analyze what this news means for investors, businesses, or the industry — connect it to Damalion's professional services (advisory, structuring, fund setup, company formation).\n";
            $prompt .= "- The article must be a Damalion expert analysis OF THIS NEWS — not a generic article with the news as a footnote.\n";

            if ( $is_url_strict ) {
                $prompt .= "\n⚠️ URL-STRICT MODE: This article is generated from a specific source URL.\n";
                $prompt .= "- The THEME field below was derived from the news content — use it as a guide, NOT as a constraint.\n";
                $prompt .= "- The CATEGORY must match the actual news topic. Choose the most accurate category from the approved list.\n";
                $prompt .= "- If the news is about data centers, the category must be 'Data centers' or 'Digital infrastructures' — NOT 'Corporate Structuring' or some generic fallback.\n";
                $prompt .= "- NEVER generate a generic article. Every paragraph must connect back to the specific news story.\n\n";
            } else {
                $prompt .= "- Ignore the THEME field below if it conflicts with this news topic. THE NEWS DRIVES THE ARTICLE.\n\n";
            }

            $prompt .= "NEWS RESEARCH:\n";
            $prompt .= "─────────────\n";
            $prompt .= trim($research_context) . "\n";
            $prompt .= "─────────────\n\n";
        }

        $prompt .= "PILLAR LINK — MANDATORY:\n";
        $prompt .= "Use this EXACT URL near the top of the article (first or second paragraph): {$pillar_url}\n";
        $prompt .= "Do NOT change this URL. Do NOT invent any other damalion.com URL. Copy-paste it exactly.\n\n";

        $prompt .= "THEME (must use exactly this, and keep it consistent in the article and image):\n";
        $prompt .= "theme: \"{$theme}\"\n\n";

        $prompt .= "INTERNAL LINKS — ABSOLUTE RULES:\n";
        $prompt .= "⚠️ You MUST use ONLY the URLs listed below. NEVER invent, guess, or fabricate any damalion.com URL.\n";
        $prompt .= "⚠️ If you create a URL that is NOT in this list, the entire article will be REJECTED.\n";
        $prompt .= "⚠️ Every href pointing to damalion.com MUST be copied character-for-character from this list or from pillar_url above.\n\n";

        $prompt .= "AVAILABLE LINKS (use these — they are real pages from our sitemap):\n";
        for ($i = 0; $i < count($satellite_links); $i++) {
            $n = $i + 1;
            $prompt .= "{$n}) {$satellite_links[$i]['url']}  —  {$satellite_links[$i]['title']}\n";
        }

        $prompt .= "\nHOW TO USE THESE LINKS:\n";
        $prompt .= "- Weave ALL of these links naturally into the body paragraphs where they are contextually relevant. Use the title or a related phrase as anchor text.\n";
        $prompt .= "- Each link must appear INSIDE a <p> paragraph as part of a sentence — never as a standalone list, never in a 'Related technical notes' section, never at the bottom.\n";
        $prompt .= "- Spread the links across different paragraphs throughout the article. Do NOT cluster them.\n";
        $prompt .= "- Use at least 3 of these links (more is fine if natural).\n";
        $prompt .= "- Do NOT create any 'Related technical notes', 'Related articles', 'Further reading', or similar section. ZERO tolerance.\n";
        $prompt .= "- Do NOT link to any damalion.com URL that is not in the list above. Zero tolerance.\n\n";

        if (!empty($existing_slugs)) {
            $prompt .= "\nEXISTING SLUGS TODAY (avoid duplicates): " . implode(', ', $existing_slugs) . "\n";
        }

        $prompt .= "\nPOST REQUIREMENTS:\n";
        $prompt .= "- title length target: 60-75 characters\n";

        // ── TITLE DIVERSITY — prevent Google duplicate structure penalty ──
        if ( ! empty( $recent_patterns ) ) {
            $prompt .= "\n⚠️ TITLE STRUCTURE DIVERSITY — CRITICAL FOR SEO:\n";
            $prompt .= "Google penalizes sites with consecutive titles that follow the same structural pattern.\n";
            $prompt .= "Your title MUST use a COMPLETELY DIFFERENT sentence structure from these recent titles:\n";
            foreach ( array_slice( $recent_patterns, -10 ) as $p ) {
                $prompt .= "  ✗ " . $p . "\n";
            }
            $prompt .= "\nVary your title structure. Use different sentence patterns such as:\n";
            $prompt .= "  ✓ \"[Topic]: What [Audience] Should Know in [Year]\" (colon structure)\n";
            $prompt .= "  ✓ \"Why [Trend] Is Changing [Industry] Across Europe\" (Why X is doing Y)\n";
            $prompt .= "  ✓ \"[Number] Key Trends in [Topic] for [Year]\" (list structure)\n";
            $prompt .= "  ✓ \"The Impact of [Event] on [Sector]\" (The + noun structure)\n";
            $prompt .= "  ✓ \"Inside [Topic]: Opportunities for [Audience]\" (Inside X structure)\n";
            $prompt .= "  ✓ \"[Topic] Explained: From [A] to [B]\" (Explained structure)\n";
            $prompt .= "  ✓ \"What [Event] Means for [Audience]\" (What X means structure)\n";
            $prompt .= "  ✓ \"[Audience] Guide to [Topic] in [Region]\" (Guide structure)\n";
            $prompt .= "If your title follows ANY of the banned patterns above, the article WILL BE REJECTED.\n\n";
        }

        $prompt .= "- excerpt length target: 140-170 characters\n";
        $prompt .= "- content_html: write a thorough, in-depth article. MINIMUM 1200 words (excluding FAQ, glossary, and CTA). Go deeper — add examples, data, practical detail. Short articles will be REJECTED.\n";
        $prompt .= "- tone: human, professional, legal/tax + practical journalism (not academic)\n";
        $prompt .= "- must include one realistic mini-case referencing at least two countries among: France, Spain, Germany, Italy, Benelux, Austria, Nordics, Poland, Czech Republic, Baltics\n";
        $prompt .= "- must include a short section like \"Practical checklist\" or \"What advisers check\" (bulleted list)\n";
        $validator_u = DBA_Validator::get_instance();
        $banned_str_u = implode(', ', $validator_u->get_banned_words());
        $prompt .= "- forbidden words in title and body: {$banned_str_u}. If you use any of these, the article WILL BE REJECTED.\n\n";

        $prompt .= "FEATURED IMAGE — CRITICAL RULES:\n";
        $prompt .= "- Write a DALL-E prompt for a REAL editorial/documentary photograph. Choose the best style for the article topic:\n";
        $prompt .= "  STYLE A — SCENE/ENVIRONMENT (for infrastructure, energy, finance, logistics, real estate topics):\n";
        $prompt .= "    * Focus on objects, architecture, infrastructure, equipment, landscapes — NO people\n";
        $prompt .= "    * Examples: solar panel array on a rooftop at golden hour | aerial view of container port with cranes | wind turbines on green hills | modern glass office building reflecting sunset | server room with blinking equipment | row of trucks in a logistics yard | conference table with financial documents\n";
        $prompt .= "  STYLE B — PROFESSIONAL IN CONTEXT (for corporate, entrepreneurship, management, consulting topics):\n";
        $prompt .= "    * 1-3 people in a REAL work environment, shot from medium distance (waist-up or wider), doing something natural\n";
        $prompt .= "    * MANDATORY for people: natural relaxed posture, NOT looking at camera, engaged in an activity (reviewing documents, walking through a facility, examining equipment, discussing at a whiteboard). Candid photojournalism style — NOT a posed corporate portrait.\n";
        $prompt .= "    * NEVER: direct eye contact with camera, arms crossed poses, thumbs up, smiling at viewer, headshots, stock photo poses, perfect symmetrical lighting on face. These create obvious AI artifacts.\n";
        $prompt .= "    * Examples: two professionals reviewing blueprints at a construction site, hard hats, shot from the side | a woman in business attire walking through a modern trading floor, motion blur, 35mm lens | an engineer inspecting solar panels on a rooftop, shot from behind, wide angle\n";
        $prompt .= "- ALWAYS include: lens focal length (e.g. 24mm, 35mm, 50mm, 85mm), lighting type (natural/ambient/golden hour/overcast), depth of field.\n";
        $prompt .= "- ABSOLUTE TEXT BAN: ZERO text of any kind. No signs, no plaques, no wall lettering, no labels, no screen text. DALL-E misspells everything.\n";
        $prompt .= "- NEVER describe: logos, brand names, abstract concepts, metaphors, glowing effects, floating objects, surreal compositions.\n";
        $prompt .= "- Think high-end editorial photography for Financial Times, Bloomberg, or The Economist.\n";
        $prompt .= "- Create SEO-friendly filename and write alt/title/caption/description (human, not stuffed)\n\n";

        $prompt .= "YOAST SEO FIELDS (mandatory — will be injected into WordPress Yoast SEO plugin):\n";
        $prompt .= "- focus_keyphrase: a single 2-4 word keyphrase that the article targets (e.g. \"Luxembourg SOPARFI dividends\", \"holding company capital gains\")\n";
        $prompt .= "- seo_title: max 60 chars, includes focus keyphrase naturally, ends with \" | Damalion\"\n";
        $prompt .= "- meta_description: 140-160 chars, includes focus keyphrase, compelling for Google SERP clicks\n\n";

        // Category guidance — AI suggestion only, category scorer will override with content-based scoring
        $scorer_tmp = DBA_Category_Scorer::get_instance();
        $cat_names  = $scorer_tmp->get_category_names_string();
        $prompt .= "CATEGORY FIELD:\n";
        $prompt .= "- For the \"category\" field, choose the SINGLE most relevant category from this approved list:\n";
        $prompt .= "  {$cat_names}\n";
        $prompt .= "- Use the exact name from the list above. Do NOT invent new categories.\n";
        $prompt .= "- The final category assignment will be verified server-side against content consistency.\n\n";

        $prompt .= "MANDATORY CTA_HTML (must be appended as the final block of content_html, exactly and once):\n";
        $prompt .= "CTA_HTML:\n{$cta_html}\n\n";

        $prompt .= "OUTPUT FORMAT:\n";
        $prompt .= "Return VALID JSON ONLY with this schema:\n\n";
        $prompt .= "{\n";
        $prompt .= "  \"post_type\": \"satellite\",\n";
        $prompt .= "  \"theme\": \"string\",\n";
        $prompt .= "  \"title\": \"string\",\n";
        $prompt .= "  \"slug\": \"string\",\n";
        $prompt .= "  \"excerpt\": \"string\",\n";
        $prompt .= "  \"category\": \"string\",\n";
        $prompt .= "  \"tags\": [\"string\",\"string\",\"string\",\"string\",\"string\"],\n";
        $prompt .= "  \"content_html\": \"string\",\n";
        $prompt .= "  \"internal_links\": {\n";
        $prompt .= "    \"pillar_url\": \"string\",\n";
        $prompt .= "    \"satellite_links\": [\n";
        $prompt .= "      {\"url\": \"string\", \"title\": \"string\"},\n";
        $prompt .= "      {\"url\": \"string\", \"title\": \"string\"},\n";
        $prompt .= "      {\"url\": \"string\", \"title\": \"string\"}\n";
        $prompt .= "    ]\n";
        $prompt .= "  },\n";
        $prompt .= "  \"featured_image\": {\n";
        $prompt .= "    \"prompt\": \"string\",\n";
        $prompt .= "    \"filename\": \"string\",\n";
        $prompt .= "    \"alt\": \"string\",\n";
        $prompt .= "    \"title\": \"string\",\n";
        $prompt .= "    \"caption\": \"string\",\n";
        $prompt .= "    \"description\": \"string\"\n";
        $prompt .= "  },\n";
        $prompt .= "  \"yoast_seo\": {\n";
        $prompt .= "    \"focus_keyphrase\": \"string\",\n";
        $prompt .= "    \"seo_title\": \"string\",\n";
        $prompt .= "    \"meta_description\": \"string\"\n";
        $prompt .= "  },\n";
        $prompt .= "  \"faqs\": [\n";
        $prompt .= "    {\"question\": \"string\", \"answer\": \"string\"}\n";
        $prompt .= "  ],\n";
        $prompt .= "  \"glossary\": [\n";
        $prompt .= "    {\"term\": \"string\", \"definition\": \"string\"}\n";
        $prompt .= "  ],\n";
        $prompt .= "  \"howto_steps\": [\n";
        $prompt .= "    {\"name\": \"string\", \"text\": \"string\"}\n";
        $prompt .= "  ],\n";
        $prompt .= "  \"speakable_text\": \"string\",\n";
        $prompt .= "  \"breadcrumb_category\": \"string\"\n";
        $prompt .= "}\n\n";

        $prompt .= "IMPORTANT:\n";
        $prompt .= "- content_html MUST contain the pillar link near top and at least 3 satellite links woven naturally into body paragraphs.\n";
        $prompt .= "- Do NOT include any 'Related technical notes', 'Related articles', or 'Further reading' section.\n";
        $prompt .= "- content_html MUST end with the exact CTA_HTML above.\n";
        $prompt .= "- Do not include any other keys.\n";
        $prompt .= "- No markdown, no commentary, JSON only.\n";

        return $prompt;
    }

    /**
     * Call OpenAI API with automatic 429 retry (exponential backoff)
     * Returns: array (parsed response body) or WP_Error
     */
    private function call_openai($body, $max_retries = 3) {
        $last_error = null;

        for ($attempt = 0; $attempt < $max_retries; $attempt++) {
            if ($attempt > 0) {
                $wait = min(pow(2, $attempt) * 10, 60); // 10s, 20s, 40s (max 60s)
                error_log('DBA OpenAI: 429 rate limit — waiting ' . $wait . 's before retry #' . ($attempt + 1));
                sleep($wait);
            }

            $response = wp_remote_post($this->api_url, array(
                'timeout' => 180,
                'headers' => array(
                    'Authorization' => 'Bearer ' . $this->api_key,
                    'Content-Type'  => 'application/json',
                ),
                'body' => json_encode($body),
            ));

            if (is_wp_error($response)) {
                $last_error = $response;
                continue;
            }

            $status_code = wp_remote_retrieve_response_code($response);

            // 429 = rate limit — retry with backoff
            if ($status_code === 429) {
                $retry_after = wp_remote_retrieve_header($response, 'retry-after');
                if ($retry_after && is_numeric($retry_after)) {
                    error_log('DBA OpenAI: Retry-After header says ' . $retry_after . 's');
                    sleep(min(intval($retry_after), 120));
                }
                $last_error = new WP_Error('rate_limit', 'OpenAI rate limit (429). Retrying…');
                continue;
            }

            // Other errors — don't retry
            if ($status_code !== 200) {
                $body_text = wp_remote_retrieve_body($response);
                return new WP_Error('api_error', "API returned status {$status_code}: " . substr($body_text, 0, 300));
            }

            // Success
            return json_decode(wp_remote_retrieve_body($response), true);
        }

        // All retries exhausted
        return $last_error ?: new WP_Error('rate_limit', 'OpenAI rate limit (429). Tried ' . $max_retries . ' times. Check your OpenAI plan at https://platform.openai.com/usage');
    }

    /**
     * Call OpenAI Responses API (for web_search tool)
     * Endpoint: /v1/responses — different from /v1/chat/completions
     * Returns: string (text output) or WP_Error
     */
    private function call_responses_api($input, $tools = array(), $instructions = '', $max_retries = 2) {
        $responses_url = 'https://api.openai.com/v1/responses';
        $last_error = null;

        $body = array(
            'model' => $this->model,
            'input' => $input,
            'tools' => $tools,
            'store' => false,  // Don't store research queries
        );
        if (!empty($instructions)) {
            $body['instructions'] = $instructions;
        }

        for ($attempt = 0; $attempt < $max_retries; $attempt++) {
            if ($attempt > 0) {
                $wait = min(pow(2, $attempt) * 10, 60);
                error_log('DBA Responses API: Rate limited — waiting ' . $wait . 's before retry #' . ($attempt + 1));
                sleep($wait);
            }

            $response = wp_remote_post($responses_url, array(
                'timeout' => 120,
                'headers' => array(
                    'Authorization' => 'Bearer ' . $this->api_key,
                    'Content-Type'  => 'application/json',
                ),
                'body' => json_encode($body),
            ));

            if (is_wp_error($response)) {
                $last_error = $response;
                continue;
            }

            $status_code = wp_remote_retrieve_response_code($response);

            if ($status_code === 429) {
                $last_error = new WP_Error('rate_limit', 'OpenAI rate limit (429)');
                continue;
            }

            if ($status_code !== 200) {
                $body_text = wp_remote_retrieve_body($response);
                return new WP_Error('api_error', "Responses API returned status {$status_code}: " . substr($body_text, 0, 500));
            }

            $data = json_decode(wp_remote_retrieve_body($response), true);

            // Extract text from output items
            $text_parts = array();
            if (!empty($data['output']) && is_array($data['output'])) {
                foreach ($data['output'] as $item) {
                    if (isset($item['type']) && $item['type'] === 'message' && !empty($item['content'])) {
                        foreach ($item['content'] as $block) {
                            if (isset($block['type']) && $block['type'] === 'output_text' && !empty($block['text'])) {
                                $text_parts[] = $block['text'];
                            }
                        }
                    }
                }
            }

            if (empty($text_parts)) {
                return new WP_Error('no_output', 'Responses API returned no text output');
            }

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

        return $last_error ?: new WP_Error('responses_failed', 'Responses API failed after ' . $max_retries . ' attempts');
    }

    /**
     * Research a news headline or URL using OpenAI web_search tool.
     * Uses the Responses API — NO Bing key needed.
     * Returns: string (research summary) or WP_Error
     */
    public function research_news($headline = '', $url = '') {
        if (empty($this->api_key)) {
            return new WP_Error('no_api_key', 'OpenAI API key not configured');
        }

        if (empty($headline) && empty($url)) {
            return new WP_Error('no_input', 'No news headline or URL provided');
        }

        error_log('DBA News Research: Starting web research — headline="' . $headline . '" url="' . $url . '"');

        // Build the research query
        $query = '';
        if (!empty($url)) {
            $query .= "Read and analyze this news article: {$url}\n\n";
        }
        if (!empty($headline)) {
            $query .= "Research this news topic: {$headline}\n\n";
        }
        $query .= "Provide a comprehensive research brief that includes:\n";
        $query .= "1. KEY FACTS: The most important facts, figures, dates, and data points\n";
        $query .= "2. CONTEXT: Background and why this matters for business/investors\n";
        $query .= "3. STAKEHOLDERS: Key companies, regulators, countries, or people involved\n";
        $query .= "4. IMPLICATIONS: What this means for investors, advisers, and companies\n";
        $query .= "5. RELATED DEVELOPMENTS: Any related recent news or trends\n\n";
        $query .= "Focus on factual, verifiable information. Include specific numbers, dates, and names where available.\n";
        $query .= "Write in plain text (no markdown). This research will be used as source material for a professional blog article.";

        $instructions = "You are a senior business research analyst. Search the web thoroughly and provide factual, detailed research. Prioritize authoritative sources (official government sites, regulatory bodies, major financial news outlets). Always include specific facts, figures, and dates.";

        $tools = array(
            array('type' => 'web_search'),
        );

        $result = $this->call_responses_api($query, $tools, $instructions);

        if (is_wp_error($result)) {
            error_log('DBA News Research: Failed — ' . $result->get_error_message());
            return $result;
        }

        error_log('DBA News Research: Success — ' . strlen($result) . ' chars of research collected');
        return $result;
    }

    /**
     * Derive the best matching theme from research context.
     * Used in URL-strict mode to pick a theme that fits the news content
     * instead of picking a random one.
     */
    public function derive_theme_from_research( $research_context ) {
        if ( empty( $research_context ) ) return '';

        $text_lower = strtolower( $research_context );

        // Score each theme by keyword presence in the research
        $best_theme = '';
        $best_score = 0;

        foreach ( $this->themes as $theme ) {
            $score = 0;
            $theme_words = preg_split( '/[\s\/&,\-–]+/', strtolower( $theme ) );
            foreach ( $theme_words as $word ) {
                if ( strlen( $word ) >= 3 && substr_count( $text_lower, $word ) > 0 ) {
                    $score += substr_count( $text_lower, $word );
                }
            }
            // Exact theme name match is a strong signal
            if ( strpos( $text_lower, strtolower( $theme ) ) !== false ) {
                $score += 10;
            }
            if ( $score > $best_score ) {
                $best_score = $score;
                $best_theme = $theme;
            }
        }

        if ( $best_score >= 2 ) {
            return $best_theme;
        }

        // No strong match — return a generic theme based on keyword analysis
        $topic_keywords = array(
            'Private Equity & Venture Capital' => array( 'private equity', 'venture capital', 'buyout', 'vc fund', 'pe fund' ),
            'Banking & Finance' => array( 'bank', 'credit', 'lending', 'interest rate', 'central bank', 'loan' ),
            'Crypto & Blockchain' => array( 'crypto', 'blockchain', 'bitcoin', 'ethereum', 'defi', 'stablecoin', 'token' ),
            'Real Estate & REITs' => array( 'real estate', 'reit', 'property', 'housing', 'commercial real estate' ),
            'Digital Infrastructure' => array( 'data center', 'data centre', 'cloud', 'digital infrastructure', 'fiber', 'telecom' ),
            'Energy & Renewables' => array( 'energy', 'solar', 'wind', 'renewable', 'oil', 'gas', 'electricity' ),
            'Fintech & Digital Payments' => array( 'fintech', 'payment', 'neobank', 'digital payment', 'regtech' ),
            'M&A and Corporate Restructuring' => array( 'merger', 'acquisition', 'm&a', 'restructuring', 'takeover', 'deal' ),
        );

        foreach ( $topic_keywords as $theme_name => $keywords ) {
            $score = 0;
            foreach ( $keywords as $kw ) {
                $score += substr_count( $text_lower, $kw );
            }
            if ( $score > $best_score ) {
                $best_score = $score;
                $best_theme = $theme_name;
            }
        }

        return $best_theme ?: 'Finance & Investment';
    }

    /**
     * Generate a single satellite article
     * Returns: parsed JSON array or WP_Error
     */
    public function generate_article($theme, $satellite_links, $existing_slugs = array(), $subject = '', $research_context = '', $cta_override = '', $is_url_strict = false, $recent_patterns = array()) {
        if (empty($this->api_key)) {
            return new WP_Error('no_api_key', 'OpenAI API key not configured');
        }

        $content_settings = get_option('dba_content_settings', array());
        $cta_html = isset($content_settings['cta_html']) ? $content_settings['cta_html'] : '';

        // FIX 2: Use per-post CTA override if provided (URL-strict mode)
        if ( ! empty( $cta_override ) ) {
            $cta_html = $cta_override;
            error_log( 'DBA: Using per-post CTA override instead of global CTA.' );
        }

        $pillar_url = isset($content_settings['pillar_url']) ? $content_settings['pillar_url'] : '';

        $system_prompt = $this->build_system_prompt($subject);
        $user_prompt = $this->build_user_prompt($theme, $satellite_links, $cta_html, $pillar_url, $existing_slugs, $subject, $research_context, $recent_patterns, $is_url_strict);
        $hardener = $this->build_hardener(!empty($research_context), $recent_patterns);

        $messages = array(
            array('role' => 'system', 'content' => $system_prompt),
            array('role' => 'system', 'content' => $hardener),
            array('role' => 'user', 'content' => $user_prompt),
        );

        $body = array(
            'model'       => $this->model,
            'messages'    => $messages,
            'max_tokens'  => 16000,
            'temperature' => 0.4,
        );

        $result = $this->call_openai($body);

        if (is_wp_error($result)) {
            return $result;
        }

        if (!isset($result['choices'][0]['message']['content'])) {
            return new WP_Error('no_content', 'OpenAI returned no content');
        }

        $raw_json = $result['choices'][0]['message']['content'];

        // Clean markdown fences if present
        $raw_json = trim($raw_json);
        if (strpos($raw_json, '```') !== false) {
            $raw_json = preg_replace('/^```(?:json)?\s*/m', '', $raw_json);
            $raw_json = preg_replace('/```\s*$/m', '', $raw_json);
            $raw_json = trim($raw_json);
        }

        $data = json_decode($raw_json, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return new WP_Error('json_error', 'Failed to parse JSON: ' . json_last_error_msg(), array('raw' => $raw_json));
        }

        // POST-PROCESSING: strip fake URLs + Last updated
        if (isset($data['content_html'])) {
            $data['content_html'] = $this->sanitize_internal_links($data['content_html'], $pillar_url, $satellite_links);

            // Remove any "Last updated" line that sneaks through
            $data['content_html'] = preg_replace('/<p[^>]*class=["\']?dba-last-updated["\']?[^>]*>.*?<\/p>/si', '', $data['content_html']);
            $data['content_html'] = preg_replace('/<p[^>]*>\s*Last updated:?\s*\d{4}[-\/]\d{1,2}[-\/]\d{1,2}\s*<\/p>/si', '', $data['content_html']);

            // Remove any "Related technical notes" / "Related articles" / "Further reading" section
            $data['content_html'] = preg_replace('/<h[23][^>]*>\s*Related\s+(technical\s+notes|articles?|reading|posts?|links?)\s*<\/h[23]>\s*<ul>[\s\S]*?<\/ul>/si', '', $data['content_html']);
            $data['content_html'] = preg_replace('/<h[23][^>]*>\s*Further\s+reading\s*<\/h[23]>\s*<ul>[\s\S]*?<\/ul>/si', '', $data['content_html']);

            // FIX 4: Strip any blog-promotion phrases that sneaked through
            $blog_promo_patterns = array(
                '/For more on[^.]*?(?:check out|visit|read|see|explore)\s+(?:the\s+)?Damalion\s+Blog[^.]*\./si',
                '/(?:check out|visit|read|see|explore)\s+(?:the\s+)?Damalion\s+Blog[^.]*\./si',
                '/(?:our|the)\s+blog\s+(?:covers|offers|provides|features|explores|discusses)[^.]*\./si',
                '/(?:head\s+(?:over\s+)?to|visit|browse|explore)\s+(?:our|the)\s+(?:Damalion\s+)?blog[^.]*\./si',
            );
            foreach ( $blog_promo_patterns as $pattern ) {
                $before = $data['content_html'];
                $data['content_html'] = preg_replace( $pattern, '', $data['content_html'] );
                if ( $data['content_html'] !== $before ) {
                    error_log( 'DBA Post-process: Stripped blog promotion phrase from content' );
                }
            }
            // Also clean up any empty paragraphs left behind
            $data['content_html'] = preg_replace( '/<p[^>]*>\s*<\/p>/', '', $data['content_html'] );

            // Auto-clean banned words from content
            $data['content_html'] = $this->strip_banned_words($data['content_html']);
        }

        // Auto-clean banned words from title
        if (isset($data['title'])) {
            $data['title'] = $this->strip_banned_words_plain($data['title']);
        }
        // Auto-clean from excerpt
        if (isset($data['excerpt'])) {
            $data['excerpt'] = $this->strip_banned_words_plain($data['excerpt']);
        }

        // Attach raw JSON for logging
        $data['_raw_json'] = $raw_json;

        return $data;
    }

    /**
     * Auto-strip banned words from HTML content.
     * Removes the word while preserving HTML tags and structure.
     */
    private function strip_banned_words($html) {
        $validator = DBA_Validator::get_instance();
        $banned = $validator->get_banned_words();
        if (empty($banned)) return $html;

        $cleaned = 0;
        // Sort by length descending so multi-word phrases are caught first
        usort($banned, function($a, $b) { return strlen($b) - strlen($a); });

        foreach ($banned as $word) {
            if (empty($word)) continue;
            $escaped = preg_quote($word, '/');

            // Match the word only in text nodes (not inside HTML tags)
            // Use a callback to avoid breaking tags
            $pattern = '/(?<=>|^)([^<]*?)(?=<|$)/s';
            $html = preg_replace_callback($pattern, function($match) use ($escaped, &$cleaned) {
                $text = $match[0];
                // Replace banned word (case-insensitive, word boundary)
                if (strpos($escaped, ' ') !== false) {
                    // Multi-word phrase: simple case-insensitive replace
                    $new = preg_replace('/' . $escaped . '/i', '', $text);
                } else {
                    // Single word: use word boundaries
                    $new = preg_replace('/\b' . $escaped . '\b/i', '', $text);
                }
                if ($new !== $text) $cleaned++;
                return $new;
            }, $html);
        }

        // Clean up artifacts: double spaces, empty commas, leading commas
        $html = preg_replace('/\s{2,}/', ' ', $html);
        $html = preg_replace('/,\s*,/', ',', $html);
        $html = preg_replace('/,\s*\./', '.', $html);
        $html = preg_replace('/\(\s*\)/', '', $html);
        $html = preg_replace('/\s+([.,;:!?])/', '$1', $html);

        if ($cleaned > 0) {
            error_log('DBA Post-process: Auto-stripped ' . $cleaned . ' banned word occurrences from content');
        }

        return $html;
    }

    /**
     * Auto-strip banned words from plain text (title, excerpt).
     */
    private function strip_banned_words_plain($text) {
        $validator = DBA_Validator::get_instance();
        $banned = $validator->get_banned_words();
        if (empty($banned)) return $text;

        usort($banned, function($a, $b) { return strlen($b) - strlen($a); });

        foreach ($banned as $word) {
            if (empty($word)) continue;
            $escaped = preg_quote($word, '/');
            if (strpos($word, ' ') !== false) {
                $text = preg_replace('/' . $escaped . '/i', '', $text);
            } else {
                $text = preg_replace('/\b' . $escaped . '\b/i', '', $text);
            }
        }

        // Clean up
        $text = preg_replace('/\s{2,}/', ' ', trim($text));
        $text = preg_replace('/,\s*,/', ',', $text);
        $text = preg_replace('/,\s*\./', '.', $text);

        return $text;
    }

    /**
     * Fix any invented/hallucinated damalion.com URLs by matching them
     * against the real sitemap registry. Falls back to removing the link
     * (keeping the anchor text) rather than pointing to /blog/.
     */
    private function sanitize_internal_links($html, $pillar_url, $satellite_links) {
        // Build allowed URL list
        $allowed = array();
        $allowed[] = rtrim($pillar_url, '/');
        $allowed[] = rtrim($pillar_url, '/') . '/';
        foreach ($satellite_links as $link) {
            $url = is_array($link) ? ($link['url'] ?? '') : $link;
            $allowed[] = rtrim($url, '/');
            $allowed[] = rtrim($url, '/') . '/';
        }

        // Load the full sitemap registry for fuzzy matching
        $full_registry = get_option('dba_full_sitemap_registry', array());

        // Find all damalion.com hrefs
        $pattern = '/href=["\'](https?:\/\/(?:www\.)?damalion\.com[^"\']*?)["\']/i';
        $html = preg_replace_callback($pattern, function($match) use ($allowed, $full_registry) {
            $url = $match[1];
            $url_clean = rtrim($url, '/');

            // Check if URL is in allowed list — keep it
            foreach ($allowed as $ok_url) {
                if (rtrim($ok_url, '/') === $url_clean) {
                    return $match[0];
                }
            }

            // Also allow lets-connect (CTA link)
            if (strpos($url, 'lets-connect') !== false) {
                return $match[0];
            }

            // ── SMART MATCH: try to find the real URL in the sitemap ──
            $invented_slug = $this->extract_slug_from_url($url);
            if (!empty($invented_slug) && !empty($full_registry)) {
                $best_match = null;
                $best_score = 0;

                foreach ($full_registry as $page) {
                    // Exact slug match
                    if ($page['slug'] === $invented_slug) {
                        $best_match = $page['url'];
                        break; // Perfect match
                    }

                    // Fuzzy match: compare slug words overlap
                    $invented_words = array_filter(explode('-', strtolower($invented_slug)), function($w) { return strlen($w) >= 3; });
                    $page_words = array_filter(explode('-', strtolower($page['slug'])), function($w) { return strlen($w) >= 3; });

                    if (empty($invented_words) || empty($page_words)) continue;

                    $common = count(array_intersect($invented_words, $page_words));
                    $score = $common / max(count($invented_words), count($page_words));

                    // Require at least 50% word overlap and minimum 2 common words
                    if ($score > $best_score && $score >= 0.5 && $common >= 2) {
                        $best_score = $score;
                        $best_match = $page['url'];
                    }
                }

                if ($best_match) {
                    error_log('DBA: Fixed invented URL "' . $url . '" → matched to "' . $best_match . '" (score: ' . round($best_score, 2) . ')');
                    return 'href="' . esc_url($best_match) . '"';
                }
            }

            // No match found — REMOVE the link entirely (keep anchor text visible)
            // This is better than pointing to /blog/ which is misleading
            error_log('DBA: Removed unmatched invented URL "' . $url . '" — no sitemap match found');
            return 'href="#" data-dba-removed="1"';
        }, $html);

        // Strip any placeholder links we marked for removal
        // Turn <a href="#" data-dba-removed="1">text</a> back into just text
        $html = preg_replace('/<a\s+href="#"\s+data-dba-removed="1"[^>]*>(.*?)<\/a>/si', '$1', $html);

        return $html;
    }

    /**
     * Extract slug from a damalion.com URL
     */
    private function extract_slug_from_url($url) {
        $path = parse_url($url, PHP_URL_PATH);
        if (empty($path)) return '';
        $path = trim($path, '/');
        $segments = explode('/', $path);
        // Return last meaningful segment
        $slug = end($segments);
        return ($slug && $slug !== 'blog') ? $slug : '';
    }

    /**
     * Translate content_html to another language using OpenAI
     */
    public function translate_article($data, $target_lang) {
        if (empty($this->api_key)) {
            return new WP_Error('no_api_key', 'OpenAI API key not configured');
        }

        $lang_names = array(
            'fr' => 'French', 'de' => 'German', 'es' => 'Spanish',
            'it' => 'Italian', 'nl' => 'Dutch', 'pt-br' => 'Brazilian Portuguese',
            'pt-pt' => 'Portuguese', 'ar' => 'Arabic', 'ru' => 'Russian',
            'zh-hans' => 'Simplified Chinese', 'he' => 'Hebrew', 'hi' => 'Hindi',
            'tr' => 'Turkish', 'da' => 'Danish', 'no' => 'Norwegian',
            'sl' => 'Slovenian', 'sv' => 'Swedish', 'cs' => 'Czech',
            'pl' => 'Polish', 'ro' => 'Romanian', 'ja' => 'Japanese',
            'ko' => 'Korean', 'hu' => 'Hungarian', 'fi' => 'Finnish',
            'el' => 'Greek', 'et' => 'Estonian', 'lv' => 'Latvian',
            'uk' => 'Ukrainian', 'az' => 'Azerbaijani',
        );

        $lang_name = isset($lang_names[$target_lang]) ? $lang_names[$target_lang] : $target_lang;

        $system = "You are a professional translator. Translate the following blog post JSON fields to {$lang_name}. Keep the JSON structure identical. Translate: title, excerpt, content_html (including HTML structure), tags, category, and featured_image fields (alt, title, caption, description). Do NOT translate URLs, slugs, or the CTA link href. Keep the CTA structure but translate its text. Output valid JSON only.";

        $messages = array(
            array('role' => 'system', 'content' => $system),
            array('role' => 'user', 'content' => json_encode($data, JSON_UNESCAPED_UNICODE)),
        );

        $body = array(
            'model'       => $this->model,
            'messages'    => $messages,
            'max_tokens'  => 16000,
            'temperature' => 0.3,
        );

        $result = $this->call_openai($body);

        if (is_wp_error($result)) return $result;

        if (!isset($result['choices'][0]['message']['content'])) {
            return new WP_Error('no_content', 'Translation returned no content');
        }

        $raw = trim($result['choices'][0]['message']['content']);
        if (strpos($raw, '```') !== false) {
            $raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
            $raw = preg_replace('/```\s*$/m', '', $raw);
            $raw = trim($raw);
        }

        $translated = json_decode($raw, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return new WP_Error('json_error', 'Failed to parse translated JSON');
        }

        return $translated;
    }
}
