<?php
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not rendered directly to HTML output.

/**
 * Costudio_WP_Publisher
 *
 * Publishes the produced article as a draft to the target WordPress site via
 * WP REST API, including Rank Math SEO meta fields.
 *
 * Markdown → HTML conversion is handled by the bundled Costudio_Vendor\Markdown
 * engine (based on Parsedown, MIT; renamed + namespaced to avoid conflicts).
 * Content is then wrapped in Gutenberg block comments so WordPress recognises
 * native blocks without the "Convert to Blocks" prompt.
 *
 * Site credentials are read from the centralized ContentOS Core repository
 * (`contentos_sites`).
 *
 * @package Contentosapp_Studio
 * @since   3.0.0
 */

defined('ABSPATH') || exit;

class Costudio_WP_Publisher
{

    const LOCAL_SITE_KEY = '__local__';

    /** Max generation attempts for a single image before it's marked terminally failed. */
    const MAX_IMAGE_ATTEMPTS = 2;

    /** Keep a deterministic provider outage blocked across chunked publish passes. */
    const IMAGE_PROVIDER_BLOCK_TTL = 900;

    /**
     * Conservative wall-clock budget (seconds) for image work in one chunked
     * publish pass — well under the durable worker's tightest lease
     * (Costudio_Publication_Jobs::WEB_LEASE_SECONDS = 360s), leaving margin
     * for the upload step and surrounding overhead. A pass with less than
     * this remaining defers instead of risking the lease expiring mid-call
     * with no checkpoint saved.
     */
    const IMAGE_PASS_SAFE_BUDGET_SECONDS = 180;

    /** Floor for a single provider HTTP attempt. Below this, don't bother trying. */
    const IMAGE_PASS_MIN_ATTEMPT_SECONDS = 30;

    private string $site_url;
    private string $auth;
    private string $site_key;
    private string $site_name = '';
    private bool   $local_mode = false;

    /**
     * Brand accent (hex) of the destination site, used to inline-style generated
     * tables during Markdown→Gutenberg conversion. Set per publish() call from
     * the production inputs; empty falls back to a neutral table header.
     */
    private string $accent_color = '';

    // ── Chunked publishing state (Phase 2) ────────────────────────────────────
    // When chunked, publish() generates at most ONE new image per pass and aborts
    // before the ledger so the runner re-dispatches the publish step for the next
    // image. Reset at the start of every publish() call.
    private bool $chunked_publish            = false;
    private int  $images_generated_this_pass = 0;
    private bool $images_pending             = false;
    private float $pass_started_at           = 0.0;
    private bool $image_provider_warning_recorded = false;

    /**
     * @param string $site_key  Key within the contentos_sites option array.
     */
    public function __construct(string $site_key)
    {
        $this->site_key = $site_key;

        $sites_repo = costudio_sites();
        $sites      = $sites_repo ? $sites_repo->get_all() : [];
        $config     = $sites[$site_key] ?? [];

        if (empty($config) || empty($config['active'])) {
            throw new Exception(__('Site is inactive (or not configured).', 'contentosapp-studio'));
        }

        $this->site_name = function_exists('costudio_site_display_name')
            ? costudio_site_display_name($config, $site_key)
            : (string) ($config['name'] ?? $config['label'] ?? $site_key);

        if ($site_key === self::LOCAL_SITE_KEY) {
            $this->local_mode = true;
            $this->site_url   = rtrim(home_url(), '/');
            $this->auth       = '';
            return;
        }

        $this->site_url = costudio_safe_http_url(rtrim((string) ($config['url'] ?? ''), '/'));
        if ($this->site_url === '') {
            throw new Exception(__('Invalid or unsafe site URL.', 'contentosapp-studio'));
        }
        $user           = $config['user']     ?? '';
        $raw_password   = Costudio_Sites_Crypto::decrypt($config['password'] ?? '');
        $this->auth     = 'Basic ' . base64_encode("{$user}:{$raw_password}");
    }

    // ── Public interface ─────────────────────────────────────────────────────

    /**
     * Publish article as draft.
     *
     * @param  array $data  Keys: output_02, output_04, output_05, inputs[], site_key
     * @return array { post_id, post_url, edit_url, title, image_errors, images_count }
     * @throws Exception
     */
    public function publish(array $data): array
    {
        if (empty($this->site_url)) {
            throw new Exception(__('Site credentials are not configured. Go to Contentosapp Studio -> Settings -> Sites.', 'contentosapp-studio'));
        }

        $production_id = (int) ($data['production_id'] ?? 0);
        $brief   = $data['output_02'] ?? '';
        $article = $data['output_04'] ?? '';
        $design  = $data['output_05'] ?? '';
        $inputs  = $data['inputs']    ?? [];

        // Brand accent for inline table styling (validated downstream in the
        // renderer). Same source of truth as the TLDR box and comparison widgets.
        $this->accent_color = (string) ($inputs['site_color'] ?? '');

        // Chunked publishing (Phase 2): generate ONE new image per pass, then
        // re-dispatch. Reset per-call state. When false, behaves as a single pass.
        $this->chunked_publish            = ! empty($data['chunked']);
        $this->images_generated_this_pass = 0;
        $this->images_pending             = false;
        $this->image_provider_warning_recorded = false;
        $this->pass_started_at            = microtime(true);

        // Update flow has a dedicated, minimal publish path. It uses the editor
        // agent's output verbatim (already Gutenberg block markup), skips
        // markdown conversion, image generation and schema injection, and PUTs
        // only `content` so the live post's title, slug, categories, Rank Math
        // meta and FAQ schema are preserved.
        $article_type = Costudio_Article_Types::normalize($inputs['article_type'] ?? '');
        if ($article_type === Costudio_Article_Types::UPDATE) {
            return $this->publish_update($production_id, $article, $inputs);
        }

        if ($this->looks_like_reviewer_json_output($article)) {
            $this->log_error(
                'publisher.publish.blocked_reviewer_json',
                [
                    'production_id' => $production_id,
                    'article_type'  => $article_type,
                    'article_head'  => mb_substr(trim($article), 0, 300),
                ],
                $production_id
            );
            throw new Exception(__('Publication blocked: Agent 04 output is an Editorial Reviewer JSON verdict, not a Markdown article.', 'contentosapp-studio'));
        }

        $this->log_info(
            'publisher.publish.start',
            [
                'production_id'   => $production_id,
                'site_key'        => $this->site_key,
                'site_url'        => $this->site_url,
                'local_mode'      => $this->local_mode,
                'article_length'  => strlen($article),
                'design_length'   => strlen($design),
                'brief_length'    => strlen($brief),
                'publish_mode'    => (string) ($inputs['publish_mode'] ?? 'draft'),
                'article_type'    => Costudio_Article_Types::normalize($inputs['article_type'] ?? ''),
            ],
            $production_id
        );
        $this->touch_publish_progress($production_id, __('Publicação: preparando título, SEO e conteúdo…', 'contentosapp-studio'), 'preparing_media');

        // ── Extract SEO fields (Shielded Format first, Labels as fallback) ─
        $title    = self::extract_editorial_title($brief, $article);
        $slug     = $this->extract_tag($brief, 'URL_SLUG')  ?: ltrim($this->extract_line($brief, ['1.3 URL Slug', 'URL Slug', '### 1.3']), '/');
        $meta     = $this->extract_tag($brief, 'META_DESC') ?: $this->extract_line($brief, ['1.2 Meta Description', 'Meta Description', 'Descrição Meta', '### 1.2']);

        $seo_title = $this->extract_tag($design, 'SEO_TITLE') ?: $this->extract_labeled($design, ['SEO Title', 'Título SEO', 'Título']);
        $seo_desc  = $this->extract_tag($design, 'META_DESC') ?: $this->extract_labeled($design, ['Meta Description', 'Descrição Meta', 'Meta Descrição']);
        $focus_kw  = $this->extract_tag($design, 'FOCUS_KW')  ?: $this->extract_labeled($design, ['Focus Keyword', 'Palavra-chave Foco', 'Keyword']);
        $tag_slug  = $this->extract_tag($design, 'URL_SLUG');

        // ── Fallbacks to Agent 04 (Writer) if metadata is missing ───────────
        // (title's Writer fallback now lives inside extract_editorial_title())
        if (empty($meta)) {
            $meta = $this->extract_labeled($article, ['Meta Description', 'Descrição Meta', 'Meta Descrição', 'Meta-descrição']);
        }
        if (empty($slug)) {
            $slug = ltrim($this->extract_labeled($article, ['Slug', 'URL Slug', 'URL']), '/');
        }

        // Provenance flags — captured before the fallback below overwrites the
        // values, so they reflect exactly what Agent 06 did (or didn't) supply.
        // Feeds the non-blocking metadata warnings (Item 2, fatia 2/3).
        $seo_title_source = ! empty($seo_title) ? 'agent_06' : 'fallback_h1';
        $seo_desc_source  = ! empty($seo_desc)  ? 'agent_06' : 'fallback_brief';

        // Normalize values.
        $seo_title = $seo_title ?: $title;
        $seo_desc  = $seo_desc  ?: $meta;
        $focus_kw  = $focus_kw  ?: ($inputs['keyword'] ?? '');
        $slug      = $tag_slug  ?: $slug;
        $excerpt   = $seo_desc ?: $meta;

        // ── SEO metadata QA warnings (Item 2, fatia 2/3) — never blocks; log-only ─
        foreach ($this->collect_seo_metadata_warnings($seo_title, $seo_title_source, $seo_desc, $seo_desc_source) as $meta_warning) {
            $this->log_warning(
                'publisher.publish.seo_metadata_warning',
                [
                    'production_id'     => $production_id,
                    'warning'           => $meta_warning,
                    'seo_title_source'  => $seo_title_source,
                    'seo_title_len'     => $this->unicode_strlen($seo_title),
                    'seo_desc_source'   => $seo_desc_source,
                    'seo_desc_len'      => $this->unicode_strlen($seo_desc),
                ],
                $production_id
            );
        }

        // ── Clean article content ──────────────────────────────────────────
        // Strip metadata header lines in all three supported locales (PT/EN/ES).
        $content = preg_replace(
            '/^(Title|Título|Meta Description|Meta Descripción|Descrição Meta|Meta Descrição|Meta-descrição|Slug|URL Slug|URL):.*$/mu',
            '',
            $article
        );
        $content = trim($content);
        $content = $this->convert_callouts($content);
        $content = $this->strip_division_markers($content);
        $content = $this->strip_leading_h1($content);
        $content = preg_replace('/^(#+)\s*H[2-6]:\s*/imu', '$1 ', $content);
        $content = $this->sanitize_placeholder_urls($content);

        // Strip the [IMAGE_LIST] structural block intended only for Agent 05
        $content = preg_replace('/\[IMAGE_LIST\].*?\[\/IMAGE_LIST\]/isu', '', $content);

        // Video marker resolution (Item 3, subitem 3A) — defensive iframe
        // strip first, then resolve [VIDEO:id] against this production's
        // validated candidate. Must run before markdown_to_gutenberg(), same
        // point in the pipeline as [IMG:...] marker resolution.
        $content = $this->strip_raw_iframes($content);
        $content = $this->resolve_video_markers($content, $inputs, $production_id);

        $content = $this->strip_editorial_disclaimers($content);
        $content = Costudio_Publisher_Schema::strip_json_ld($content);
        $content = Costudio_Publication_Recovery::strip_markers($content);

        // (References rendering moved to post-markdown stage for robustness)

        // ── Image generation & upload ──────────────────────────────────────
        $featured_media_id = 0;
        $featured_media_url = '';
        $image_errors      = [];
        $site_key          = $data['site_key'] ?? '';
        $generator         = new Costudio_Image_Generator($site_key);
        $generator->set_production_id($production_id);

        // Base name for semantic filenames (SEO).
        $base_filename = ! empty($focus_kw) ? $focus_kw : (! empty($slug) ? $slug : 'imagem');
        $base_filename = sanitize_title($base_filename);

        $cts        = costudio_settings();
        // Use per-production snapshot when available (set at creation time).
        $image_mode = $inputs['costudio_image_mode']
            ?? ($cts ? (string) $cts->get('costudio_image_mode', 'generate') : 'generate');

        // Comparison articles always force placeholder mode — AI-generated
        // product photos look artificial and mislead buyers. If the user
        // explicitly chose 'none', we respect it; anything else → placeholder.
        if ('comparison' === ($inputs['article_type'] ?? '') && 'none' !== $image_mode) {
            $image_mode = 'placeholder';
        }

        $this->log_debug(
            'publisher.publish.image_mode',
            [
                'production_id' => $production_id,
                'image_mode'    => $image_mode,
                'site_key'      => $site_key,
                'generator'     => $generator->get_debug_context(),
                'seo_title_len' => strlen($seo_title),
                'seo_desc_len'  => strlen($seo_desc),
                'meta_len'      => strlen($meta),
                'excerpt_len'   => strlen($excerpt),
            ],
            $production_id
        );

        // ── Image generation decision diagnostics ─────────────────────────
        $gen_enabled = $generator->is_enabled();

        $this->log_info(
            'publisher.publish.image_decision',
            [
                'production_id'  => $production_id,
                'image_mode'     => $image_mode,
                'gen_enabled'    => $gen_enabled,
                'can_image'      => true,
                'provider'       => $generator->get_debug_context()['provider'] ?? '',
                'local_mode'     => $generator->get_debug_context()['local_mode'] ?? false,
                'has_api_key'    => $generator->get_debug_context()['has_api_key'] ?? false,
            ],
            $production_id
        );
        $this->touch_publish_progress($production_id, __('Publicação: avaliando imagens e mídia destacada…', 'contentosapp-studio'), 'preparing_media');

        if ('none' === $image_mode) {
            // Strip all [IMG:...] markers from the article — use a greedy
            // pattern that also catches markers embedded inline in paragraphs,
            // which the line-anchored regex would have missed.
            $content = preg_replace('/\[IMG:[^\]]+\]/u', '', $content);
            $content = preg_replace("/\n{3,}/", "\n\n", trim($content));
        } elseif ('generate' === $image_mode && $gen_enabled) {
            $image_qty  = isset($inputs['costudio_image_quantity'])
                ? intval($inputs['costudio_image_quantity'])
                : ($cts ? intval($cts->get('costudio_image_quantity', 2)) : 2);
            $image_ai_mode = (string) ($inputs['execution_ai_mode'] ?? $inputs['ai_mode'] ?? ($cts ? $cts->get('ai_mode', '') : ''));
            $image_qty  = class_exists('Costudio_Standalone_Settings')
                ? Costudio_Standalone_Settings::clamp_image_quantity($image_qty, $image_ai_mode)
                : max(1, min(3, $image_qty));
            $max_inline = max(0, $image_qty - 1); // reserve 1 slot for cover (imagem1)

            // ── Resumable image manifest (idempotent publishing) ───────────
            // Each generated image is checkpointed here. On a retry after a
            // server timeout, `done` entries are reused instead of regenerated
            // (no double cost, no orphan attachments). The manifest is keyed by
            // a source_hash over the design output + image settings, so it
            // self-invalidates when the content/settings change.
            // Hash over the article (output_04 — holds the [IMG:] markers), the
            // design (output_05 — holds the prompts), and the image settings, so
            // reprocessing Agent 04 (changed markers/content) invalidates a stale
            // manifest even when the design output is unchanged.
            $image_source_hash = hash('sha256', (string) $article . '|' . (string) $design . '|' . $image_mode . '|' . (string) $image_qty);
            $manifest = Costudio_Production::get_images_manifest($production_id);
            if (($manifest['source_hash'] ?? '') !== $image_source_hash) {
                $manifest = ['source_hash' => $image_source_hash, 'items' => []];
            }

            $this->touch_publish_progress($production_id, __('Publicação: preparando geração e upload das imagens…', 'contentosapp-studio'));

            // Build a map of image prompts from Agent 05 output.
            $all_prompts    = $this->extract_image_prompts($design);
            $prompts_by_img = [];
            foreach ($all_prompts as $img) {
                $prompts_by_img[$img['filename']] = $img;
            }

            if ($image_qty >= 1 && ! isset($prompts_by_img['imagem1.webp'])) {
                $cover_recovery = $this->recover_cover_prompt(
                    $all_prompts,
                    $seo_title,
                    $seo_desc ?: $meta,
                    $focus_kw,
                    $inputs,
                    $production_id
                );
                if (! empty($cover_recovery['prompt'])) {
                    $prompts_by_img['imagem1.webp'] = $cover_recovery['prompt'];
                }
            }

            $this->log_debug(
                'publisher.publish.image_prompts_parsed',
                [
                    'production_id'   => $production_id,
                    'prompts_found'   => count($all_prompts),
                    'filenames'       => array_keys($prompts_by_img),
                    'has_cover'       => isset($prompts_by_img['imagem1.webp']),
                    'design_length'   => strlen($design),
                ],
                $production_id
            );

            // Placeholders to protect Gutenberg blocks from double-wrapping by Parsedown.
            $img_blocks    = [];
            $inline_count  = 0;

            // Replace [IMG:filename.webp | Alt] markers BEFORE Markdown → HTML conversion.
            $content = preg_replace_callback(
                '/\[IMG:\s*([^\|\]]+\.webp)\s*\|\s*([^\]]+)\]/i',
                function (array $m) use (
                    &$inline_count,
                    $max_inline,
                    $prompts_by_img,
                    $generator,
                    &$image_errors,
                    &$img_blocks,
                    &$manifest,
                    $production_id,
                    $base_filename
                ) {
                    if ($inline_count >= $max_inline) {
                        return ''; // Exceeds qty limit — remove marker.
                    }

                    $filename = strtolower(trim($m[1]));
                    $alt      = trim($m[2]);
                    $img_data = $prompts_by_img[$filename] ?? null;

                    if (! $img_data || empty($img_data['prompt'])) {
                        return ''; // No matching prompt in Agent 05 output — remove marker.
                    }

                    try {
                        // Generate SEO friendly filename
                        $num_str = str_replace(['imagem', '.webp'], '', strtolower($filename));
                        $num = (int) $num_str;
                        $seo_filename = sprintf('%s-%d.webp', $base_filename, $num ?: $inline_count + 2);
                        $this->touch_publish_progress(
                            $production_id,
                            sprintf(
                                /* translators: %s: image filename being generated. */
                                __('Publicação: gerando imagem %s…', 'contentosapp-studio'),
                                $filename
                            )
                        );

                        $result  = $this->generate_or_reuse_image(
                            $production_id,
                            $manifest,
                            $filename,
                            $img_data['prompt'],
                            $alt ?: $img_data['alt'],
                            $img_data['caption'],
                            $seo_filename,
                            $generator
                        );
                        $inline_count++;
                        $this->touch_publish_progress(
                            $production_id,
                            sprintf(
                                /* translators: %s: image filename uploaded to WordPress. */
                                __('Publicação: imagem %s enviada ao WordPress.', 'contentosapp-studio'),
                                $filename
                            )
                        );

                        $url     = esc_url($result['url']);
                        $alt_esc = esc_attr($alt ?: $img_data['alt']);
                        $caption = ! empty($img_data['caption'])
                            ? '<figcaption class="wp-element-caption">' . esc_html($img_data['caption']) . '</figcaption>'
                            : '';

                        $block_html = "<!-- wp:image {\"sizeSlug\":\"large\"} -->\n"
                            . "<figure class=\"wp-block-image size-large\">"
                            . "<img src=\"{$url}\" alt=\"{$alt_esc}\" />"
                            . $caption
                            . "</figure>\n"
                            . "<!-- /wp:image -->";

                        // Use an HTML comment placeholder so Parsedown passes it through
                        // without wrapping, and markdown_to_gutenberg() won't double-wrap.
                        $placeholder             = '<!-- ___IMG_BLOCK_' . count($img_blocks) . '___ -->';
                        $img_blocks[$placeholder] = $block_html;

                        return "\n\n{$placeholder}\n\n";
                    } catch (Costudio_Image_Deferred_Exception $img_e) {
                        // Chunked publishing: this image waits for a later pass.
                        // Keep the marker untouched — this pass aborts before publish.
                        $this->images_pending = true;
                        return $m[0];
                    } catch (Costudio_Image_Plan_Limited_Exception $img_e) {
                        $this->mark_image_terminal($production_id, $manifest, $filename, 'plan_limited');
                        $this->log_info(
                            'publisher.publish.inline_image.plan_limited',
                            [
                                'production_id' => $production_id,
                                'filename'      => $filename,
                                'reason'        => $img_e->get_reason(),
                                'message'       => 'Image generation skipped by the current plan or image allowance. [IMG:] marker kept for manual insertion.',
                            ],
                            $production_id
                        );
                        return $m[0]; // Keep [IMG:] placeholder for manual insertion.
                    } catch (Costudio_Image_Provider_Unavailable_Exception $img_e) {
                        $this->record_image_provider_unavailable(
                            $production_id,
                            $manifest,
                            $img_e,
                            $image_errors
                        );
                        return ''; // Never expose a raw [IMG:] marker in the published article.
                    } catch (Exception $img_e) {
                        $this->mark_image_terminal($production_id, $manifest, $filename, 'failed', $img_e->getMessage());
                        $image_errors[] = "[{$filename}] " . $img_e->getMessage();
                        $this->log_error(
                            'publisher.publish.inline_image.failed',
                            [
                                'production_id' => $production_id,
                                'filename'      => $filename,
                                'error'         => $img_e->getMessage(),
                            ],
                            $production_id
                        );
                        return ''; // Remove broken marker.
                    }
                },
                $content
            );

            // Generate cover image (imagem1.webp) separately — not inserted in body.
            if (isset($prompts_by_img['imagem1.webp']) && $image_qty >= 1) {
                $cover = $prompts_by_img['imagem1.webp'];
                $seo_cover_filename = sprintf('%s-capa.webp', $base_filename);

                try {
                    $this->touch_publish_progress($production_id, __('Publicação: gerando imagem destacada…', 'contentosapp-studio'));
                    $result            = $this->generate_or_reuse_image(
                        $production_id,
                        $manifest,
                        'imagem1.webp',
                        $cover['prompt'],
                        $cover['alt'],
                        $cover['caption'],
                        $seo_cover_filename,
                        $generator
                    );
                    $featured_media_id  = $result['attachment_id'];
                    $featured_media_url = (string) ($result['url'] ?? '');
                    $this->touch_publish_progress($production_id, __('Publicação: imagem destacada enviada ao WordPress.', 'contentosapp-studio'));
                } catch (Costudio_Image_Deferred_Exception $img_e) {
                    // Chunked publishing: defer the cover to a later pass.
                    $this->images_pending = true;
                } catch (Costudio_Image_Plan_Limited_Exception $img_e) {
                    $this->mark_image_terminal($production_id, $manifest, 'imagem1.webp', 'plan_limited');
                    $this->log_info(
                        'publisher.publish.cover_image.plan_limited',
                        [
                            'production_id' => $production_id,
                            'filename'      => 'imagem1.webp',
                            'reason'        => $img_e->get_reason(),
                            'message'       => 'Cover image generation skipped by the current plan or image allowance.',
                        ],
                        $production_id
                    );
                } catch (Costudio_Image_Provider_Unavailable_Exception $img_e) {
                    $this->record_image_provider_unavailable(
                        $production_id,
                        $manifest,
                        $img_e,
                        $image_errors
                    );
                } catch (Exception $img_e) {
                    $this->mark_image_terminal($production_id, $manifest, 'imagem1.webp', 'failed', $img_e->getMessage());
                    $image_errors[] = '[imagem1.webp] ' . $img_e->getMessage();
                    $this->log_error(
                        'publisher.publish.cover_image.failed',
                        [
                            'production_id' => $production_id,
                            'filename'      => 'imagem1.webp',
                            'error'         => $img_e->getMessage(),
                        ],
                        $production_id
                    );
                }
            } elseif ($image_qty >= 1) {
                $image_errors[] = __('Cover image skipped: "imagem1.webp" prompt was not found in Agent 05 output. Check if the Design agent generated the expected image sections.', 'contentosapp-studio');
                $this->touch_publish_progress($production_id, __('Publicação: imagem destacada ignorada; prompt visual ausente.', 'contentosapp-studio'));
                $this->log_error(
                    'publisher.publish.cover_image.prompt_missing',
                    [
                        'production_id'  => $production_id,
                        'prompts_found'  => array_keys($prompts_by_img),
                        'image_qty'      => $image_qty,
                        'design_snippet' => substr($design, 0, 400),
                    ],
                    $production_id
                );
            }

            // ── Chunked publishing: defer remaining images to later passes ──
            // A new image was generated this pass and more remain. Abort BEFORE
            // the ledger/post creation so the runner re-dispatches the publish
            // step for the next image. The image produced this pass is already
            // checkpointed in the manifest, so nothing is lost or repeated.
            if ($this->chunked_publish && $this->images_pending) {
                $this->log_info(
                    'publisher.publish.chunked_pending',
                    [ 'production_id' => $production_id, 'generated_this_pass' => $this->images_generated_this_pass ],
                    $production_id
                );
                return [ 'pending' => true, 'image_errors' => $image_errors ];
            }
        } elseif ('generate' === $image_mode && ! $gen_enabled) {
            // Mode is generate but provider is not configured — strip markers.
            $image_errors[] = __('Images skipped: image provider is not enabled or not configured. Check Settings → AI → Image Provider.', 'contentosapp-studio');
            $this->log_error(
                'publisher.publish.images_skipped.provider_disabled',
                [
                    'production_id' => $production_id,
                    'provider'      => $generator->get_debug_context()['provider'] ?? '',
                    'has_api_key'   => $generator->get_debug_context()['has_api_key'] ?? false,
                    'local_mode'    => $generator->get_debug_context()['local_mode'] ?? false,
                ],
                $production_id
            );
            $content = preg_replace('/^\s*\[IMG:[^\]]+\]\s*$/mu', '', $content);
            $content = preg_replace("/\n{3,}/", "\n\n", trim($content));
        } elseif ('placeholder' === $image_mode) {
            // Convert [IMG:...] markers to styled editorial placeholder blocks.
            // Uses Agent 06 prompts for the brief text when available.
            // Inline CSS ensures the block renders correctly on any remote WP theme.
            $all_prompts    = $this->extract_image_prompts($design);
            $prompts_by_img = [];
            foreach ($all_prompts as $img) {
                $prompts_by_img[$img['filename']] = $img;
            }

            $img_blocks = [];

            $content = preg_replace_callback(
                '/\[IMG:\s*([^\|\]]+\.webp)\s*\|\s*([^\]]+)\]/i',
                function (array $m) use ($prompts_by_img, &$img_blocks) {
                    $filename     = strtolower(trim($m[1]));
                    $alt          = trim($m[2]);
                    $img_data     = $prompts_by_img[$filename] ?? null;
                    $prompt_text  = $img_data && ! empty($img_data['prompt'])  ? (string) $img_data['prompt']  : '';
                    $caption_text = $img_data && ! empty($img_data['caption']) ? (string) $img_data['caption'] : '';
                    $alt_final    = $alt ?: ( $img_data ? (string) ( $img_data['alt'] ?? '' ) : '' );

                    $meta_rows = '';
                    if ($alt_final) {
                        $meta_rows .= '<p style="margin:0 0 4px;font-size:12px;color:#555;">'
                            . '<strong>Alt:</strong> <em>' . esc_html($alt_final) . '</em></p>';
                    }
                    if ($caption_text) {
                        $meta_rows .= '<p style="margin:0 0 4px;font-size:12px;color:#555;">'
                            . '<strong>' . esc_html__('Caption:', 'contentosapp-studio') . '</strong> ' . esc_html($caption_text) . '</p>';
                    }
                    if ($prompt_text) {
                        $meta_rows .= '<p style="margin:0 0 8px;font-size:12px;color:#6c47ff;">'
                            . '&#x1F4A1; ' . esc_html($prompt_text) . '</p>';
                    }

                    $block_html = "<!-- wp:html -->\n"
                        . '<figure class="fos-img-placeholder"'
                        . ' data-filename="' . esc_attr($filename) . '"'
                        . ($prompt_text ? ' data-prompt="' . esc_attr($prompt_text) . '"' : '')
                        . ' style="border:2px dashed #6c47ff;border-radius:8px;padding:20px 20px 16px;background:#f8f6ff;margin:24px 0;display:flex;align-items:flex-start;gap:14px;">'
                        . "\n"
                        . '<span aria-hidden="true" style="font-size:28px;flex-shrink:0;line-height:1;margin-top:2px;">&#x1F5BC;&#xFE0F;</span>'
                        . "\n"
                        . '<div style="min-width:0;flex:1;">'
                        . "\n"
                        . '<strong style="display:block;font-size:13px;color:#1a1a2e;margin-bottom:8px;">'
                        . esc_html__('Insert image here', 'contentosapp-studio')
                        . '</strong>'
                        . "\n"
                        . $meta_rows
                        . '<code style="font-size:11px;color:#6c47ff;background:rgba(108,71,255,.08);padding:2px 6px;border-radius:4px;">'
                        . esc_html($filename)
                        . '</code>'
                        . "\n"
                        . '</div>'
                        . "\n"
                        . '</figure>'
                        . "\n<!-- /wp:html -->";

                    $placeholder             = '<!-- ___IMG_BLOCK_' . count($img_blocks) . '___ -->';
                    $img_blocks[$placeholder] = $block_html;

                    return "\n\n{$placeholder}\n\n";
                },
                $content
            );
        }

        // ── Comparison markers: [COMPRAR:], [PROS], [CONTRAS], [FAVORITOS_BOX] ─
        // Must run before markdown_to_gutenberg() so Parsedown never sees the
        // raw markers. Blocks are protected via ___CMP_BLOCK_N___ placeholders
        // and restored after Markdown conversion (mirroring $img_blocks flow).
        $cmp_blocks = [];
        if ('comparison' === ($inputs['article_type'] ?? '')) {
            $content = $this->process_comparison_markers($content, $inputs, $cmp_blocks);
        }

        $this->touch_publish_progress($production_id, __('Publicação: renderizando referências e conteúdo final…', 'contentosapp-studio'), 'rendering_content');
        $content = $this->normalize_tldr_blockquote_to_wp_html($content, $production_id);
        $content = $this->normalize_tldr_markdown_list_to_wp_html($content, $production_id);

        // ── References Renderer: resolve {{ref:N}} placeholders ────────────
        // Executed PRE-Markdown so that markdown_to_gutenberg() correctly converts
        // the appended Markdown references into native Gutenberg blocks.
        //
        // Defence-in-depth: regardless of whether the renderer runs (i.e.,
        // whether the production has any data to build a section), we ALWAYS
        // strip the LLM's manual `## Referências` block. The Writer
        // occasionally emits one with hallucinated/broken URLs even when
        // instructed not to, and shipping that to the CMS bypasses the
        // validation layer entirely.
        if ($production_id > 0 && class_exists('Costudio_References_Renderer')) {
            $renderer = Costudio_References_Renderer::from_production($production_id);
            if ($renderer) {
                $content_lang = $this->resolve_content_locale($inputs);
                $content = $renderer->render(
                    $content,
                    Costudio_Article_Types::normalize($inputs['article_type'] ?? ''),
                    $content_lang
                );
            } else {
                // No data of any kind (no sources_json, no internal/external links).
                // 1. Strip the LLM's manual `## Referências` so hallucinated URLs
                //    can't reach the CMS.
                // 2. Strip any orphan {{ref:N}} tokens (rendered <a> + bare).
                $content = Costudio_References_Renderer::strip_manual_references($content);
                $content = preg_replace('/<a\s+[^>]*href="\{\{ref:[a-z0-9\-_]+\}\}"[^>]*>(.*?)<\/a>/iu', '$1', $content);
                $content = preg_replace('/\{\{ref:[a-z0-9\-_]+\}\}/u', '', $content);
                $content = preg_replace('/[ \t]{2,}/u', ' ', $content);
                costudio_log_debug(
                    'publisher.references_fallback_strip',
                    array(
                        'production_id' => $production_id,
                        'action'        => 'manual_block_and_orphans_stripped',
                    )
                );
            }
        }

        // ── Hallucination guard: ungrounded external links ─────────────────
        // When the production has NO grounded (web-searched) sources, the Writer
        // had nothing verified to cite — any external link it produced is
        // fabricated. Strip those deterministically. User-provided externals and
        // internal site links are whitelisted and preserved.
        if ($production_id > 0 && class_exists('Costudio_References_Renderer')) {
            $allowed_hosts = $this->allowed_link_hosts_when_ungrounded($production_id);
            if (is_array($allowed_hosts)) {
                $content = Costudio_References_Renderer::strip_ungrounded_external_links($content, $allowed_hosts);
            }
        }

        // ── Markdown → Gutenberg ───────────────────────────────────────────
        $content = $this->markdown_to_gutenberg($content);

        // Restore Gutenberg image blocks that were protected via placeholders.
        if (! empty($img_blocks)) {
            foreach ($img_blocks as $placeholder => $block_html) {
                $content = str_replace(
                    "<!-- wp:paragraph -->\n<p>{$placeholder}</p>\n<!-- /wp:paragraph -->",
                    $block_html,
                    $content
                );
                // Fallback: replace bare placeholder if not wrapped in paragraph block.
                $content = str_replace($placeholder, $block_html, $content);
            }
        }

        // Restore Gutenberg comparison blocks (CTA buttons, Prós/Contras, Favoritos box).
        if (! empty($cmp_blocks)) {
            foreach ($cmp_blocks as $placeholder => $block_html) {
                $content = str_replace(
                    "<!-- wp:paragraph -->\n<p>{$placeholder}</p>\n<!-- /wp:paragraph -->",
                    $block_html,
                    $content
                );
                $content = str_replace($placeholder, $block_html, $content);
            }
        }

        // ── News byline ───────────────────────────────────────────────────────
        // Insert the visible byline ("By Author • date and time") between the
        // post's <h1> (rendered by WordPress) and the article body. This is
        // standard portal-style journalism furniture and a meaningful E-E-A-T
        // signal — the reader sees who wrote it and when. Only applied for
        // article_type === NEWS to keep the visual contract of other types
        // unchanged (pillar/satellite/comparison/update do not get a byline
        // until they explicitly opt in).
        if ($article_type === Costudio_Article_Types::NEWS) {
            $byline_html = $this->render_news_byline($inputs);
            if ('' !== $byline_html) {
                $content = $byline_html . "\n\n" . $content;
            }
        }

        // ── Deterministic pre-publish QA gate (Item 3) ────────────────────────
        // Auto-correct the two safe defects (empty headings, body <h1> -> <h2>),
        // then hard-block on unambiguous leaks. Path-agnostic: the local/remote
        // branch is below.
        $qa_fix  = $this->autocorrect_content_defects($content);
        $content = $qa_fix['content'];
        foreach ($qa_fix['corrections'] as $qa_correction) {
            $this->log_warning(
                'publisher.publish.qa_autocorrected',
                ['production_id' => $production_id, 'fix' => $qa_correction],
                $production_id
            );
        }
        $qa_blockers = $this->scan_publish_quality($content, $title);
        if (! empty($qa_blockers)) {
            $this->log_error(
                'publisher.publish.qa_blocked',
                ['production_id' => $production_id, 'blockers' => $qa_blockers],
                $production_id
            );
            throw new Exception(
                __('Publication blocked by QA gate: ', 'contentosapp-studio') . implode('; ', $qa_blockers)
            );
        }

        // ── Experimental, non-blocking: commercial/price claim without a
        //    nearby source link (Item 2, subitem 2B) ──────────────────────────
        foreach ($this->scan_commercial_price_warnings($content) as $price_warning) {
            $this->log_warning(
                'publisher.publish.price_claim_warning',
                ['production_id' => $production_id, 'warning' => $price_warning],
                $production_id
            );
        }

        // ── Extract and inject JSON-LD Schema (Agent 06) ──────────────────────
        $schema_markup = $this->extract_json_ld($design);

        // FAQ parity: rebuild the FAQPage schema from the VISIBLE FAQ so the
        // structured data can never diverge from what the reader sees. Runs
        // before the news transform and before both publish branches, so both
        // local and remote get the single-source-of-truth FAQ schema.
        if (! empty($schema_markup)) {
            $schema_markup = $this->sync_faq_schema_to_visible($schema_markup, $content, $production_id);
        }

        // For NEWS, post-process the schema to guarantee the four critical
        // fields no agent should be trusted to invent on its own:
        //   1. @type promoted/forced to NewsArticle
        //   2. author as Person with name + url (from the WP_User picked by
        //      the editor — never an Organization fallback)
        //   3. datePublished + dateModified as ISO 8601 with site timezone
        //      (resolved at publish time, not at prompt time)
        //   4. publisher.logo.url filled from the WP site icon
        // The agent's other schema fields (headline, description, image,
        // articleBody hints, etc.) are preserved as emitted.
        if (! empty($schema_markup) && $article_type === Costudio_Article_Types::NEWS) {
            $schema_markup = $this->transform_news_schema($schema_markup, $inputs, $production_id);
        }

        if (! empty($schema_markup) && ! $this->local_mode) {
            // Normalize image/date placeholders before the first remote write.
            // The post-creation pass below remains authoritative and overwrites
            // these fallback dates with the real values returned by WordPress.
            // Keeping this first pass is also important for ledger recovery: a
            // recovered checkpoint only contains id/link/title, so it may not
            // have REST date fields available for the final pass.
            $fallback_date = $this->resolve_schema_publish_iso($inputs);
            $schema_markup = $this->apply_runtime_article_schema(
                $schema_markup,
                [
                    'image_url'      => $featured_media_url,
                    'date_published' => $fallback_date,
                    'date_modified'  => $fallback_date,
                ]
            );
            $content .= $schema_markup;
        }

        // ── Telemetry: Image Generation Batch ───────────────────────────────
        if (class_exists('Costudio_Telemetry') && 'generate' === $image_mode) {
            $images_ok = ($featured_media_id ? 1 : 0) + (int) $inline_count;

            Costudio_Telemetry::queue('image.generated', [
                'production_id'  => $production_id,
                'production_uid' => class_exists('Costudio_Production') ? Costudio_Production::tracking_uid_for($production_id) : '',
                'category'       => 'image',
                'count_ok'       => $images_ok,
                'image_count'    => $images_ok,
                'count_fail'     => count($image_errors),
                'provider'       => $generator->get_debug_context()['provider'] ?? '',
                'model'          => $generator->get_debug_context()['model'] ?? '',
            ]);
        }

        // ── Category ──────────────────────────────────────────────────────────
        $is_update_cat = Costudio_Article_Types::normalize($inputs['article_type'] ?? '') === Costudio_Article_Types::UPDATE;
        $this->touch_publish_progress($production_id, __('Publicação: preparando categoria, schema e metadados…', 'contentosapp-studio'), 'rendering_content');

        if ($this->local_mode) {
            $cat_id = (! $is_update_cat) ? $this->get_or_create_category_local($inputs['categoria'] ?? '') : null;
        } else {
            $cat_id = (! $is_update_cat) ? $this->get_or_create_category($inputs['categoria'] ?? '') : null;
        }

        // ── Publish mode ───────────────────────────────────────────────────────
        $publish_mode = $inputs['publish_mode'] ?? 'draft';
        $status       = 'draft';
        $date         = '';

        if ('published' === $publish_mode) {
            $status = 'future' === $publish_mode ? 'future' : 'publish';
        } elseif ('scheduled' === $publish_mode) {
            $status = 'future';
            $date   = $inputs['publish_date'] ?? '';
        }

        $is_update = Costudio_Article_Types::normalize($inputs['article_type'] ?? '') === Costudio_Article_Types::UPDATE;
        $update_id = (int) ($inputs['update_post_id'] ?? 0);
        $publication_claim = null;
        $idempotency_key   = '';

        if (! $is_update && $production_id > 0) {
            $production_uid = Costudio_Production::tracking_uid_for($production_id);
            $inputs[Costudio_Production::TRACKING_UID_INPUT] = $production_uid;
            $idempotency_key = Costudio_Publication_Ledger::idempotency_key($this->site_key, $production_uid);
            if (! $this->local_mode) {
                $content = Costudio_Publication_Recovery::ensure_marker($content, $idempotency_key);
            }
            $publication_claim = Costudio_Publication_Ledger::claim(
                $production_id,
                $production_uid,
                $this->site_key,
                [
                    'title'          => $title,
                    'status'         => $status,
                    'slug'           => $slug,
                    'date'           => $date,
                    'source_hash'    => hash( 'sha256', $brief . "\n" . $article . "\n" . $design . "\n" . wp_json_encode( $inputs ) ),
                ]
            );
            if ('conflict' === $publication_claim['status']) {
                throw new Exception(__('Publication blocked: the idempotency key is already linked to a different payload.', 'contentosapp-studio'));
            }
            if ('unavailable' === $publication_claim['status']) {
                throw new Exception(__('Publication protection is unavailable because its database ledger is missing or does not use InnoDB.', 'contentosapp-studio'));
            }
            if ('busy' === $publication_claim['status']) {
                throw new Costudio_Publication_Busy_Exception(__('Publication is already being processed. Try again shortly.', 'contentosapp-studio'));
            }
            if ('existing' === $publication_claim['status']) {
                return $this->publication_result_from_ledger($publication_claim['row'], $title, $image_errors, $featured_media_id);
            }
        }

        // ── Branch: local vs remote ────────────────────────────────────────────
        if ($this->local_mode) {
            $this->touch_publish_progress($production_id, __('Publicação: criando rascunho local no WordPress…', 'contentosapp-studio'), 'creating_post');
            $this->log_info(
                'publisher.publish.local_branch',
                [
                    'production_id'      => $production_id,
                    'featured_media_id'  => $featured_media_id,
                    'image_errors_count' => count($image_errors),
                ],
                $production_id
            );
            $ledger_post_id = (int) ($publication_claim['row']['post_id'] ?? 0);
            if (! $ledger_post_id && '' !== $idempotency_key) {
                $recovered_local = Costudio_Publication_Recovery::find_local_post($idempotency_key);
                $ledger_post_id  = (int) ($recovered_local['id'] ?? 0);
            }
            try {
                $result = $this->finish_local(
                    $title,
                    $content,
                    $status,
                    $slug,
                    $date,
                    $cat_id,
                    $featured_media_id,
                    $featured_media_url,
                    ['focus_kw' => $focus_kw, 'seo_title' => $seo_title, 'seo_desc' => $seo_desc, 'excerpt' => $excerpt],
                    $is_update || $ledger_post_id > 0,
                    $ledger_post_id > 0 ? $ledger_post_id : $update_id,
                    $image_errors,
                    $idempotency_key,
                    $production_id,
                    (string) ($publication_claim['claim_token'] ?? ''),
                    $schema_markup
                );
                if ($publication_claim) {
                    if (! Costudio_Publication_Ledger::checkpoint($production_id, $publication_claim['claim_token'], (int) $result['post_id'], (string) $result['post_url'], (string) $result['edit_url'])) {
                        throw new Exception(__('The local post was created but its publication checkpoint could not be saved.', 'contentosapp-studio'));
                    }
                    if (! Costudio_Publication_Ledger::succeed($production_id, $publication_claim['claim_token'])) {
                        throw new Exception(__('The local post was finalized but its publication ledger could not be completed.', 'contentosapp-studio'));
                    }
                }
            } catch (\Throwable $e) {
                if ($publication_claim) {
                    Costudio_Publication_Ledger::fail($production_id, $publication_claim['claim_token'], $e->getMessage());
                }
                throw $e;
            }

            $this->touch_publish_progress($production_id, __('Publicação: rascunho local criado com sucesso.', 'contentosapp-studio'), 'finalizing_post');
        } else {

            // ── Build REST payload ─────────────────────────────────────────────────
            $payload = [
                'title'      => $title,
                'content'    => $content,
                'excerpt'    => $excerpt,
                'status'     => $status,
                'slug'       => $slug,
                'categories' => $cat_id ? [$cat_id] : [],
                'meta'       => [
                    'rank_math_focus_keyword' => $focus_kw,
                    'rank_math_title'         => $seo_title,
                    'rank_math_description'   => $seo_desc,
                ],
            ];

            if ($date) {
                $payload['date'] = gmdate('Y-m-d\TH:i:s', strtotime($date));
            }

            if ($featured_media_id) {
                $payload['featured_media'] = $featured_media_id;
            }
            if ($publication_claim) {
                Costudio_Publication_Ledger::store_prepared_payload($production_id, $publication_claim['claim_token'], $payload);
            }

            $this->touch_publish_progress($production_id, __('Publicação: enviando rascunho para o WordPress remoto…', 'contentosapp-studio'), 'creating_post');
            if ($is_update && $update_id) {
                unset($payload['slug']);
                unset($payload['categories']);

                $response = wp_safe_remote_request("{$this->site_url}/wp-json/wp/v2/posts/{$update_id}", costudio_safe_remote_args([
                    'method'  => 'PUT',
                    'timeout' => 30,
                    'headers' => [
                        'Authorization' => $this->auth,
                        'Content-Type'  => 'application/json',
                    ],
                    'body' => wp_json_encode($payload),
                ]));
            } else {
                $body = null;
                $recovered = false;
                $ledger_post_id = (int) ($publication_claim['row']['post_id'] ?? 0);
                if ($ledger_post_id > 0) {
                    $body = [
                        'id'    => $ledger_post_id,
                        'link'  => (string) ($publication_claim['row']['post_url'] ?? ''),
                        'title' => [ 'rendered' => $title ],
                    ];
                    $response = null;
                    $recovered = true;
                } else {
                    $body = Costudio_Publication_Recovery::find_remote_post($this->site_url, $this->auth, $idempotency_key, 1);
                    $recovered = is_array($body);
                    if (! $body) {
                        Costudio_Publication_Ledger::mark_creating($production_id, $publication_claim['claim_token']);
                        $response = wp_safe_remote_post("{$this->site_url}/wp-json/wp/v2/posts", costudio_safe_remote_args([
                            'timeout' => 30,
                            'headers' => [
                                'Authorization' => $this->auth,
                                'Content-Type'  => 'application/json',
                            ],
                            'body' => wp_json_encode($payload),
                        ]));
                        if (is_wp_error($response)) {
                            $body = Costudio_Publication_Recovery::find_remote_post($this->site_url, $this->auth, $idempotency_key, 4);
                            $recovered = is_array($body);
                            if ($recovered) {
                                $response = null;
                            }
                        }
                    } else {
                        $response = null;
                    }
                }
            }

            if (isset($response) && is_wp_error($response) && ! is_array($body)) {
                $err_msg = $response->get_error_message();
                if ($publication_claim) {
                    Costudio_Publication_Ledger::fail($production_id, $publication_claim['claim_token'], $err_msg);
                }
                $this->log_error(
                    'publisher.publish.remote_request.wp_error',
                    [
                        'production_id' => $production_id,
                        'error'         => $err_msg,
                        'site_url'      => $this->site_url,
                    ],
                    $production_id
                );
                if (class_exists('Costudio_Telemetry')) {
                    Costudio_Telemetry::queue('publish.failed', ['error' => $err_msg, 'site' => $this->site_url]);
                }
                throw new Exception(__('WP REST API error:', 'contentosapp-studio') . ' ' . $err_msg);
            }

            $code = isset($response) && $response ? (int) wp_remote_retrieve_response_code($response) : 200;
            $raw  = isset($response) && $response ? (string) wp_remote_retrieve_body($response) : '';
            if (! is_array($body)) {
                $body = json_decode($raw, true);
            }
            if (
                ! $is_update
                && '' !== $idempotency_key
                && (! in_array($code, [200, 201], true) || ! is_array($body) || empty($body['id']))
            ) {
                $recovery_body = Costudio_Publication_Recovery::find_remote_post($this->site_url, $this->auth, $idempotency_key, 4);
                if (is_array($recovery_body) && ! empty($recovery_body['id'])) {
                    $body      = $recovery_body;
                    $code      = 200;
                    $raw       = '';
                    $response  = null;
                    $recovered = true;
                }
            }

            $this->log_debug(
                'publisher.publish.remote_response',
                [
                    'production_id' => $production_id,
                    'code'          => $code,
                    'snippet'       => substr($raw, 0, 240),
                    'recovered'     => ! empty($recovered),
                    'idempotency_key' => $idempotency_key,
                ],
                $production_id
            );

            if (! in_array($code, [200, 201], true)) {
                $msg = $body['message'] ?? $raw;
                if ($publication_claim) {
                    Costudio_Publication_Ledger::fail($production_id, $publication_claim['claim_token'], (string) $msg);
                }
                if (class_exists('Costudio_Telemetry')) {
                    Costudio_Telemetry::queue('publish.failed', ['code' => $code, 'error' => $msg, 'site' => $this->site_url]);
                }
                throw new Exception("WP REST returned HTTP {$code}: {$msg}");
            }

            $post_id  = (int) $body['id'];
            $post_url = $body['link']   ?? "{$this->site_url}/?p={$post_id}";
            $edit_url = "{$this->site_url}/wp-admin/post.php?post={$post_id}&action=edit";
            if ($publication_claim && ! Costudio_Publication_Ledger::checkpoint($production_id, $publication_claim['claim_token'], $post_id, $post_url, $edit_url)) {
                Costudio_Publication_Ledger::fail($production_id, $publication_claim['claim_token'], 'Remote post checkpoint failed.');
                throw new Exception(__('The remote post was created but its publication checkpoint could not be saved.', 'contentosapp-studio'));
            }
            $this->touch_publish_progress($production_id, __('Publicação: post remoto criado, aplicando ajustes finais…', 'contentosapp-studio'), 'finalizing_post');
            if ($publication_claim) {
                Costudio_Publication_Ledger::mark_finalizing($production_id, $publication_claim['claim_token']);
            }

            $runtime_content = $this->apply_runtime_article_schema(
                $content,
                [
                    'image_url'      => $featured_media_url,
                    'date_published' => $this->normalize_rest_datetime_to_iso8601((string) ($body['date_gmt'] ?? $body['date'] ?? '')),
                    'date_modified'  => $this->normalize_rest_datetime_to_iso8601((string) ($body['modified_gmt'] ?? $body['modified'] ?? $body['date_gmt'] ?? $body['date'] ?? '')),
                    'page_url'       => (string) $post_url,
                ]
            );

            if ($runtime_content !== $content) {
                if (! $this->update_remote_post_content($post_id, $runtime_content, $title, $production_id)) {
                    if ($publication_claim) {
                        Costudio_Publication_Ledger::fail($production_id, $publication_claim['claim_token'], 'Remote post finalization failed.');
                    }
                    throw new Exception(__('The post was created, but its final schema update failed. Retrying will reuse the existing post.', 'contentosapp-studio'));
                }
                $content = $runtime_content;
            }

            $result = [
                'post_id'      => $post_id,
                'post_url'     => $post_url,
                'edit_url'     => $edit_url,
                'title'        => html_entity_decode($body['title']['rendered'] ?? $title, ENT_QUOTES, 'UTF-8'),
                'image_errors' => $image_errors,
                'images_count' => $featured_media_id ? 1 : 0,
                'publication_state' => $recovered ? 'existing' : 'created',
                'recovered'     => $recovered,
                'idempotency_key' => $idempotency_key,
            ];
            if ($publication_claim) {
                if (! Costudio_Publication_Ledger::succeed($production_id, $publication_claim['claim_token'])) {
                    throw new Exception(__('The remote post was finalized but its publication ledger could not be completed.', 'contentosapp-studio'));
                }
                $result['publication_state'] = 'finalized';
            }
        }

        // Both publishing branches return the same result contract. Normalize
        // the common values here so post-publish telemetry never depends on
        // branch-local variables (the local branch previously left the two
        // URL variables undefined even though finish_local() returned them).
        $post_id  = (int) ($result['post_id'] ?? 0);
        $post_url = (string) ($result['post_url'] ?? '');
        $edit_url = (string) ($result['edit_url'] ?? '');

        // ── Telemetry: Post Published ──────────────────────────────────────
        if (class_exists('Costudio_Telemetry')) {
            $production_uid = Costudio_Production::tracking_uid_for($production_id);
            Costudio_Telemetry::queue('post.published', array_merge([
                'production_id'    => $production_id,
                'production_uid'   => $production_uid,
                'locale'           => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
                'type'             => Costudio_Article_Types::normalize($inputs['article_type'] ?? ''),
                'has_seo_meta'     => ! empty($focus_kw),
                'images_published' => $featured_media_id ? 1 : 0,
                'images_failed'    => count($image_errors),
            ], $this->publication_telemetry_context(
                (int) $post_id,
                (string) $post_url,
                (string) $edit_url,
                (string) ($result['publication_state'] ?? '')
            )));

            if (! empty($focus_kw)) {
                Costudio_Telemetry::queue('seo.meta_applied', [
                    'production_id'   => $production_id,
                    'production_uid'  => $production_uid,
                    'has_focus_kw'    => true,
                    'has_title'       => ! empty($seo_title),
                    'has_description' => ! empty($seo_desc),
                ]);
            }
        }

        $this->log_info(
            'publisher.publish.success',
            [
                'production_id'      => $production_id,
                'post_id'            => $post_id,
                'featured_media_id'  => $featured_media_id,
                'image_errors_count' => count($image_errors),
            ],
            $production_id
        );

        return $result;
    }

    /**
     * Update flow: PUT only `content` to the live post.
     *
     * The agent emits a per-section action protocol ([SECTION N] KEEP/EDIT/
     * DELETE/INSERT). We rebuild the article by serializing the ORIGINAL
     * Gutenberg blocks for KEEP sections (byte-for-byte, no validator
     * warnings) and converting EDIT/INSERT Markdown into fresh Gutenberg
     * blocks. The original post's title, slug, categories, Rank Math meta
     * and FAQ/schema are preserved because we only send `content` (and
     * optionally `status`).
     *
     * If the agent's response cannot be parsed as the section protocol
     * (e.g. legacy prompt still deployed), we fall back to the previous
     * Markdown-rewrite path so the user is never blocked.
     *
     * @throws Exception
     */
    private function publish_update(int $production_id, string $article, array $inputs): array
    {
        if (trim($article) === '') {
            throw new Exception(__('Update: editor agent produced no content.', 'contentosapp-studio'));
        }

        $update_id = (int) ($inputs['update_post_id'] ?? 0);
        if ($update_id <= 0) {
            throw new Exception(__('Update: missing or invalid target post ID.', 'contentosapp-studio'));
        }

        // Title is intentionally NOT sent in the PUT payload. Update flow only
        // edits the body, and omitting `title` makes WordPress preserve whatever
        // is on the live post — protecting any manual title edits the user may
        // have made between fetch and publish.
        $title = sanitize_text_field($inputs['update_post_title'] ?? '');

        // Pre-rebuild diagnostics: capture the input shape before any
        // transformation so the error_log shows whether the original post
        // content arrived with Gutenberg block delimiters intact. This is
        // the single source of truth for diagnosing future "the article was
        // overwritten with only the intro" regressions — if has_block_markup
        // is false here, the upstream fetch (REST or local) returned
        // rendered HTML and the Sectioner is about to abort.
        $original_content = (string) ($inputs['update_post_content'] ?? '');
        $this->log_info(
            'publisher.publish_update.input_shape',
            [
                'production_id'      => $production_id,
                'update_id'          => $update_id,
                'local_mode'         => $this->local_mode,
                'original_chars'     => strlen($original_content),
                'has_block_markup'   => class_exists('Costudio_Update_Sectioner')
                    ? Costudio_Update_Sectioner::has_block_markup($original_content)
                    : (false !== strpos($original_content, '<!-- wp:')),
                'agent_output_chars' => strlen($article),
            ],
            $production_id
        );

        $content = $this->rebuild_update_content($article, $inputs, $production_id);

        // Pre-publish QA gate (Item 3) — the update flow rebuilds content outside
        // publish(), so run the same checks here (a Sectioner parse failure could
        // otherwise leave "[SECTION N]" live). Title is preserved from the live
        // post and never travels in the PUT, so it is NOT required.
        $qa_fix  = $this->autocorrect_content_defects($content);
        $content = $qa_fix['content'];
        foreach ($qa_fix['corrections'] as $qa_correction) {
            $this->log_warning(
                'publisher.publish_update.qa_autocorrected',
                ['production_id' => $production_id, 'fix' => $qa_correction],
                $production_id
            );
        }
        $qa_blockers = $this->scan_publish_quality($content, $title, false);
        if (! empty($qa_blockers)) {
            $this->log_error(
                'publisher.publish_update.qa_blocked',
                ['production_id' => $production_id, 'blockers' => $qa_blockers],
                $production_id
            );
            throw new Exception(
                __('Publication blocked by QA gate: ', 'contentosapp-studio') . implode('; ', $qa_blockers)
            );
        }

        // ── Experimental, non-blocking: commercial/price claim without a
        //    nearby source link (Item 2, subitem 2B) — updates can introduce
        //    an unsourced price just as easily as a new production ──────────
        foreach ($this->scan_commercial_price_warnings($content) as $price_warning) {
            $this->log_warning(
                'publisher.publish_update.price_claim_warning',
                ['production_id' => $production_id, 'warning' => $price_warning],
                $production_id
            );
        }

        $publish_mode = $inputs['publish_mode'] ?? 'draft';
        $status_override = null;
        if ('published' === $publish_mode) {
            $status_override = 'publish';
        } elseif ('scheduled' === $publish_mode) {
            $status_override = 'future';
        }

        $this->log_info(
            'publisher.publish_update.start',
            [
                'production_id' => $production_id,
                'update_id'     => $update_id,
                'local_mode'    => $this->local_mode,
                'content_len'   => strlen($content),
                'publish_mode'  => $publish_mode,
            ],
            $production_id
        );

        $this->touch_publish_progress($production_id, __('Publicação: aplicando atualização cirúrgica…', 'contentosapp-studio'));

        if ($this->local_mode) {
            return $this->finish_update_local($update_id, $content, $title, $status_override, $production_id);
        }

        $payload = ['content' => $content];
        if ($status_override) {
            $payload['status'] = $status_override;
        }

        $response = wp_safe_remote_request(
            "{$this->site_url}/wp-json/wp/v2/posts/{$update_id}",
            costudio_safe_remote_args([
                'method'  => 'PUT',
                'timeout' => 30,
                'headers' => [
                    'Authorization' => $this->auth,
                    'Content-Type'  => 'application/json',
                ],
                'body' => wp_json_encode($payload),
            ])
        );

        if (is_wp_error($response)) {
            $err_msg = $response->get_error_message();
            $this->log_error(
                'publisher.publish_update.wp_error',
                ['production_id' => $production_id, 'error' => $err_msg],
                $production_id
            );
            throw new Exception(__('WP REST API error:', 'contentosapp-studio') . ' ' . $err_msg);
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = (string) wp_remote_retrieve_body($response);
        $body = json_decode($raw, true);

        if (! in_array($code, [200, 201], true)) {
            $msg = is_array($body) && isset($body['message']) ? $body['message'] : $raw;
            throw new Exception("WP REST returned HTTP {$code}: {$msg}");
        }

        $post_id  = (int) ($body['id'] ?? $update_id);
        $post_url = $body['link'] ?? "{$this->site_url}/?p={$post_id}";
        $edit_url = "{$this->site_url}/wp-admin/post.php?post={$post_id}&action=edit";

        if (class_exists('Costudio_Telemetry')) {
            Costudio_Telemetry::queue('post.published', array_merge([
                'production_id'    => $production_id,
                'production_uid'   => Costudio_Production::tracking_uid_for($production_id),
                'locale'           => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
                'type'             => Costudio_Article_Types::UPDATE,
                'has_seo_meta'     => false,
                'images_published' => 0,
                'images_failed'    => 0,
            ], $this->publication_telemetry_context(
                (int) $post_id,
                (string) $post_url,
                (string) $edit_url,
                'updated'
            )));
        }

        $this->log_info(
            'publisher.publish_update.success',
            ['production_id' => $production_id, 'post_id' => $post_id],
            $production_id
        );

        return [
            'post_id'      => $post_id,
            'post_url'     => $post_url,
            'edit_url'     => $edit_url,
            'title'        => html_entity_decode($body['title']['rendered'] ?? $title, ENT_QUOTES, 'UTF-8'),
            'image_errors' => [],
            'images_count' => 0,
        ];
    }

    private function finish_update_local(int $post_id, string $content, string $title, ?string $status_override, int $production_id): array
    {
        $update = [
            'ID'           => $post_id,
            'post_content' => $content,
        ];
        if ($status_override) {
            $update['post_status'] = $status_override;
        }

        $result_id = wp_update_post($update, true);
        if (is_wp_error($result_id)) {
            throw new Exception($result_id->get_error_message());
        }

        $post_url = get_permalink($post_id) ?: home_url("/?p={$post_id}");
        $edit_url = get_edit_post_link($post_id, 'raw') ?: admin_url("post.php?post={$post_id}&action=edit");

        if (class_exists('Costudio_Telemetry')) {
            Costudio_Telemetry::queue('post.published', array_merge([
                'production_id'    => $production_id,
                'production_uid'   => Costudio_Production::tracking_uid_for($production_id),
                'locale'           => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
                'type'             => Costudio_Article_Types::UPDATE,
                'has_seo_meta'     => false,
                'images_published' => 0,
                'images_failed'    => 0,
            ], $this->publication_telemetry_context(
                (int) $post_id,
                (string) $post_url,
                (string) $edit_url,
                'updated'
            )));
        }

        return [
            'post_id'      => (int) $post_id,
            'post_url'     => $post_url,
            'edit_url'     => $edit_url,
            'title'        => $title,
            'image_errors' => [],
            'images_count' => 0,
        ];
    }

    private function publication_telemetry_context(int $post_id, string $post_url, string $edit_url, string $state): array
    {
        $destination_url = costudio_safe_http_url($this->site_url);
        $host = (string) wp_parse_url($post_url ?: $destination_url, PHP_URL_HOST);
        if ('' === $host) {
            $host = (string) wp_parse_url($destination_url, PHP_URL_HOST);
        }
        $host = (string) preg_replace('/^www\./i', '', strtolower($host));

        return [
            'destination_site_key'  => sanitize_key($this->site_key),
            'destination_site_name' => sanitize_text_field($this->site_name ?: $this->site_key),
            'destination_site_url'  => esc_url_raw($destination_url),
            'destination_site_host' => sanitize_text_field($host),
            'wp_post_id'            => $post_id,
            'wp_post_url'           => esc_url_raw($post_url),
            'wp_edit_url'           => esc_url_raw($edit_url),
            'publication_state'     => sanitize_key($state),
        ];
    }

    /**
     * Rebuild the Update article by combining preserved sections (original
     * Gutenberg blocks, byte-for-byte) with EDIT/INSERT Markdown converted
     * to fresh blocks.
     *
     * If the agent's response cannot be parsed as the [SECTION N] protocol,
     * we fall back to prepare_update_content_legacy() so the user is never
     * stuck — degraded but functional publishing.
     */
    private function rebuild_update_content(string $article, array $inputs, int $production_id): string
    {
        $original = (string) ($inputs['update_post_content'] ?? '');

        // No original content stored (defensive) → legacy path is the only option.
        if ('' === trim($original) || ! class_exists('Costudio_Update_Sectioner')) {
            return $this->prepare_update_content_legacy($article);
        }

        // Hard guard: the Update flow can only operate on raw Gutenberg
        // block markup. If the original content arrived as rendered HTML
        // (no <!-- wp:... --> delimiters), parse_blocks() would return a
        // single freeform block containing the whole article, the agent
        // would edit "section 0" with only a partial rewrite, and the
        // publisher would overwrite the live post with that fragment —
        // exactly the cascade-damage bug from the May 21 incident.
        //
        // Abort loudly instead of falling back to the legacy rewrite path,
        // because legacy was designed for "agent ignored the protocol",
        // not for "input is structurally broken". Failing here keeps the
        // live post intact and surfaces the upstream fetch problem
        // (Application Password lacking edit_posts, or a Classic-editor
        // post the user tried to update).
        if ( ! Costudio_Update_Sectioner::has_block_markup($original) ) {
            $this->log_info(
                'publisher.publish_update.aborted_no_block_markup',
                [
                    'production_id' => $production_id,
                    'original_chars' => strlen($original),
                    'reason'        => 'update_post_content_missing_gutenberg_delimiters',
                ],
                $production_id
            );
            throw new Exception(
                __( 'Update aborted: the original post content arrived without Gutenberg block markup. This usually means the source site returned rendered HTML (the Application Password user is missing edit_posts capability) or the post was authored in the Classic editor. The live post was not modified.', 'contentosapp-studio' )
            );
        }

        $sections = Costudio_Update_Sectioner::split($original);
        $actions  = Costudio_Update_Sectioner::parse_agent_response($article);

        // Parser found zero actionable sections → agent didn't follow the
        // protocol (probably legacy prompt). Fall back to the prior rewrite
        // path so the publish still succeeds.
        if (empty($actions['sections']) && empty($actions['insert_after']) && empty($actions['insert_before_first'])) {
            $this->log_info(
                'publisher.publish_update.protocol_fallback',
                [
                    'production_id' => $production_id,
                    'reason'        => 'no_section_actions_parsed',
                    'article_chars' => strlen($article),
                ],
                $production_id
            );
            return $this->prepare_update_content_legacy($article);
        }

        $publisher = $this;
        $content = Costudio_Update_Sectioner::rebuild(
            $sections,
            $actions,
            static function (string $md) use ($publisher): string {
                return $publisher->markdown_to_gutenberg_public($md);
            },
            (string) ($inputs['update_post_title'] ?? '')
        );

        $this->log_info(
            'publisher.publish_update.rebuilt',
            [
                'production_id'   => $production_id,
                'sections_total'  => count($sections),
                'sections_edited' => count(array_filter($actions['sections'], static fn($a) => 'EDIT' === ($a['type'] ?? ''))),
                'sections_kept'   => count(array_filter($actions['sections'], static fn($a) => 'KEEP' === ($a['type'] ?? ''))),
                'sections_deleted' => count(array_filter($actions['sections'], static fn($a) => 'DELETE' === ($a['type'] ?? ''))),
                'inserts'         => array_sum(array_map('count', $actions['insert_after'] ?? array())) + count($actions['insert_before_first'] ?? array()),
            ],
            $production_id
        );

        return $content;
    }

    /**
     * Public wrapper around markdown_to_gutenberg so the Sectioner closure
     * can reach it without breaking the private contract elsewhere.
     */
    public function markdown_to_gutenberg_public(string $markdown): string
    {
        return $this->markdown_to_gutenberg($markdown);
    }

    /**
     * Build the visible news byline as a Gutenberg HTML block.
     *
     * Looks like portal-style journalism furniture: "By Author Name • Day,
     * Month DD, YYYY at HH:MM" with the author name hyperlinked to the WP
     * author archive page. Returns empty string when the author ID is
     * missing or no longer resolves — defensive, since the validator
     * upstream already enforces a valid author at production creation.
     *
     * @param array<string,mixed> $inputs
     */
    private function render_news_byline(array $inputs): string
    {
        $author_id = (int) ($inputs['news_author_id'] ?? 0);
        if ($author_id <= 0) {
            return '';
        }
        $user = function_exists('get_userdata') ? get_userdata($author_id) : null;
        if (! $user) {
            return '';
        }

        $name = (string) $user->display_name;
        $url  = function_exists('get_author_posts_url') ? (string) get_author_posts_url($author_id) : '';

        // Date phrasing follows the locale via wp_date — "21 de maio de 2026 às 14:32" in
        // pt-BR, "May 21, 2026 at 2:32 PM" in en-US, etc. WordPress handles l10n; we
        // just provide a format suited to portal-style typography.
        $date_format = function_exists('get_option') ? (string) get_option('date_format', 'F j, Y') : 'F j, Y';
        $time_format = function_exists('get_option') ? (string) get_option('time_format', 'H:i') : 'H:i';
        $human_dt    = function_exists('wp_date')
            ? (string) wp_date($date_format . ' \\à\\s ' . $time_format)
            : gmdate($date_format . ' ' . $time_format);

        $by_label    = function_exists('__') ? __('By', 'contentosapp-studio') : 'By';
        $bullet      = ' · ';
        $name_esc    = esc_html($name);
        $url_esc     = esc_url($url);
        $human_esc   = esc_html($human_dt);
        $by_esc      = esc_html($by_label);

        if ('' !== $url) {
            $author_html = '<a href="' . $url_esc . '" rel="author" style="color:inherit;text-decoration:none;border-bottom:1px solid currentColor;">' . $name_esc . '</a>';
        } else {
            $author_html = $name_esc;
        }

        return "<!-- wp:html -->\n"
            . '<p class="fos-news-byline" style="color:#6b7280;font-size:0.92em;margin:0 0 1.6em 0;line-height:1.5;">'
            . $by_esc . ' ' . $author_html . $bullet . $human_esc
            . "</p>\n"
            . '<!-- /wp:html -->';
    }

    /**
     * Post-process the JSON-LD schema markup emitted by Agent 06 to enforce
     * the four NewsArticle properties that must never depend on the agent's
     * imagination:
     *
     *   - @type forced to "NewsArticle" (agent may emit "Article" by habit)
     *   - author re-shaped as {@type:Person, name, url} using the WP_User
     *     selected by the editor — overrides any "Organization" fallback
     *   - datePublished / dateModified set to the current ISO 8601 datetime
     *     with the site timezone (resolved here, at publish moment, not at
     *     prompt-generation moment — scheduled drafts get the right value)
     *   - publisher.logo.url filled from the WP site icon when present
     *
     * Other schema fields the agent emits (headline, description, image,
     * articleSection, articleBody hints, etc.) are preserved as-is so the
     * agent retains creative ownership of the content-derived metadata.
     *
     * The input is the already-wrapped Gutenberg HTML block produced by
     * Costudio_Publisher_Schema::extract_json_ld(); we parse out the inner
     * <script> payloads, transform them, and re-wrap with the same envelope.
     *
     * @param array<string,mixed> $inputs
     */
    private function transform_news_schema(string $schema_markup, array $inputs, int $production_id): string
    {
        if ('' === trim($schema_markup)) {
            return $schema_markup;
        }

        if (! preg_match_all('/<script\s+type="application\/ld\+json"\s*>(.*?)<\/script>/is', $schema_markup, $matches)) {
            // No script payloads found inside the wrapper — return unchanged.
            return $schema_markup;
        }

        $author_id      = (int) ($inputs['news_author_id'] ?? 0);
        $author_user    = ($author_id > 0 && function_exists('get_userdata')) ? get_userdata($author_id) : null;
        $author_name    = $author_user ? (string) $author_user->display_name : '';
        $author_url     = ($author_id > 0 && function_exists('get_author_posts_url')) ? (string) get_author_posts_url($author_id) : '';
        $publisher_name = trim((string) ($inputs['nicho'] ?? ''));
        if ('' === $publisher_name && function_exists('get_bloginfo')) {
            $publisher_name = (string) get_bloginfo('name');
        }
        $publisher_logo = function_exists('get_site_icon_url') ? (string) get_site_icon_url(512) : '';
        $iso_now        = function_exists('wp_date') ? (string) wp_date('c') : gmdate('c');

        $transformed_count = 0;

        foreach ($matches[1] as $i => $payload) {
            $payload_trim = trim($payload);
            $decoded      = json_decode($payload_trim, true);
            if (! is_array($decoded)) {
                continue;
            }

            // Schema can be a single @graph wrapper or a flat object. Walk both.
            if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
                foreach ($decoded['@graph'] as &$node) {
                    $this->apply_news_schema_overrides(
                        $node,
                        $author_name,
                        $author_url,
                        $publisher_name,
                        $publisher_logo,
                        $iso_now
                    );
                }
                unset($node);
            } else {
                $this->apply_news_schema_overrides(
                    $decoded,
                    $author_name,
                    $author_url,
                    $publisher_name,
                    $publisher_logo,
                    $iso_now
                );
            }

            $reencoded = wp_json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
            if (false === $reencoded || null === $reencoded) {
                continue; // Keep original on encode failure — defensive.
            }

            $original_block = $matches[0][$i];
            $replacement    = '<script type="application/ld+json">' . $reencoded . '</script>';
            $schema_markup  = str_replace($original_block, $replacement, $schema_markup);
            $transformed_count++;
        }

        $this->log_info(
            'publisher.publish.news_schema_transformed',
            [
                'production_id'    => $production_id,
                'scripts_total'    => count($matches[0]),
                'scripts_changed'  => $transformed_count,
                'has_author'       => '' !== $author_name,
                'has_publisher'    => '' !== $publisher_name,
                'has_logo'         => '' !== $publisher_logo,
                'iso_datetime'     => $iso_now,
            ],
            $production_id
        );

        return $schema_markup;
    }

    /**
     * Apply the four NewsArticle overrides to a single schema node (mutated
     * by reference). Only acts on nodes whose @type is Article/NewsArticle/
     * BlogPosting — leaves WebPage, FAQPage, Person, BreadcrumbList etc.
     * alone so the rest of the @graph keeps its semantics.
     *
     * @param array<string,mixed> $node
     */
    private function apply_news_schema_overrides(
        array &$node,
        string $author_name,
        string $author_url,
        string $publisher_name,
        string $publisher_logo,
        string $iso_now
    ): void {
        $type = $node['@type'] ?? '';
        if (is_array($type)) {
            // @type can be an array of types — normalise to the first match.
            $type_match = false;
            foreach ($type as $t) {
                if (in_array($t, ['Article', 'NewsArticle', 'BlogPosting'], true)) {
                    $type_match = true;
                    break;
                }
            }
            if (! $type_match) {
                return;
            }
        } elseif (! in_array((string) $type, ['Article', 'NewsArticle', 'BlogPosting'], true)) {
            return;
        }

        // Force the semantic type to NewsArticle.
        $node['@type'] = 'NewsArticle';

        // Author as Person (overrides Organization fallback / placeholder).
        if ('' !== $author_name) {
            $author = [
                '@type' => 'Person',
                'name'  => $author_name,
            ];
            if ('' !== $author_url) {
                $author['url'] = $author_url;
            }
            $node['author'] = $author;
        }

        // datePublished / dateModified — overwrite whatever the agent emitted
        // (including "AUTO" placeholder or stale [YEAR]-MM-DD strings).
        if ('' !== $iso_now) {
            $node['datePublished'] = $iso_now;
            $node['dateModified']  = $iso_now;
        }

        // Publisher block. If absent, build from scratch; if present, fill
        // missing logo without clobbering name (agent may have chosen a
        // brand-specific publisher name).
        if (! isset($node['publisher']) || ! is_array($node['publisher'])) {
            $node['publisher'] = [
                '@type' => 'Organization',
                'name'  => $publisher_name !== '' ? $publisher_name : 'Publisher',
            ];
        } else {
            $node['publisher']['@type'] = 'Organization';
            if (empty($node['publisher']['name']) && '' !== $publisher_name) {
                $node['publisher']['name'] = $publisher_name;
            }
        }
        if ('' !== $publisher_logo) {
            $node['publisher']['logo'] = [
                '@type' => 'ImageObject',
                'url'   => $publisher_logo,
            ];
        }
    }

    /**
     * Legacy normalization path — used as a fallback when the agent's output
     * cannot be parsed as the per-section action protocol (e.g. an older
     * prompt is still deployed). Strips metadata header / fences and
     * stash-converts mixed Markdown+Gutenberg content to native blocks.
     */
    private function prepare_update_content_legacy(string $article): string
    {
        $content = trim($article);

        // Strip ALL fenced code wrappers (start, end, or stray). The legacy
        // Markdown prompt occasionally closes the metadata header with a
        // bare ``` line that ends up in the body.
        $content = preg_replace('/^\s*```[a-zA-Z]*\s*$/m', '', (string) $content);

        // Drop the leading metadata block (Title / Meta / Slug / Keyword).
        $content = $this->strip_update_metadata_header((string) $content);
        $content = trim((string) $content);

        // Mixed-content handling: the agent may return a clean Gutenberg
        // payload, pure Markdown, raw HTML, or any mix of the three. To make
        // every case land as native Gutenberg blocks (so the editor never
        // shows "Convert to blocks"), we:
        //   1. Stash every <!-- wp:* --> ... <!-- /wp:* --> block so the
        //      Markdown converter doesn't touch them.
        //   2. Run the remaining text through markdown_to_gutenberg, which
        //      wraps paragraphs / headings / lists / figures in native
        //      block markers.
        //   3. Restore the stashed blocks at their original positions.
        //
        // Placeholders are HTML comments (not underscore-delimited tokens)
        // because Parsedown treats ___text___ as bold+italic emphasis and
        // would mangle the placeholder; HTML comments pass through Parsedown
        // verbatim.
        $stashed = [];
        $content = preg_replace_callback(
            '/<!--\s*wp:([a-z][a-z0-9-]*)(?:\s+\{[^}]*\})?\s*(?:\/-->|-->[\s\S]*?<!--\s*\/wp:\1\s*-->)/i',
            function ($m) use (&$stashed) {
                $key            = '<!--FOSUPD-BLOCK-' . count($stashed) . '-->';
                $stashed[$key]  = $m[0];
                return "\n\n" . $key . "\n\n";
            },
            $content
        );

        $content = $this->strip_leading_h1($content);
        $content = $this->convert_callouts($content);
        $content = $this->markdown_to_gutenberg($content);

        if (! empty($stashed)) {
            // Markdown_to_gutenberg wraps standalone tokens in wp:paragraph;
            // peel that wrapper off before restoring, otherwise we end up
            // with a paragraph block containing the original block.
            $content = preg_replace(
                '/<!--\s*wp:paragraph\s*-->\s*<p>\s*(<!--FOSUPD-BLOCK-\d+-->)\s*<\/p>\s*<!--\s*\/wp:paragraph\s*-->/',
                '$1',
                $content
            );
            $content = str_replace(array_keys($stashed), array_values($stashed), $content);
        }

        return $content;
    }

    /**
     * Remove the Writer agent's metadata header (Título/Meta Description/Slug)
     * if present at the top. The header is required by the regular publish
     * flow but must NOT appear in the body of an Update.
     */
    private function strip_update_metadata_header(string $content): string
    {
        $lines       = preg_split("/\r\n|\r|\n/", $content);
        $skip_until  = 0;
        $meta_regex  = '/^(?:Título|Title|Meta Description|Descrição Meta|Meta Descrição|Meta-descrição|Slug|URL Slug|URL|Palavra-chave|Focus Keyword|Keyword|Focus Keyphrase|Categoria|Category)\s*:/i';
        $saw_meta    = false;

        foreach ($lines as $i => $line) {
            $trim = trim($line);
            if ($trim === '') {
                $skip_until = $i + 1;
                continue;
            }
            if (preg_match($meta_regex, $trim)) {
                $saw_meta   = true;
                $skip_until = $i + 1;
                continue;
            }
            break;
        }

        return $saw_meta ? implode("\n", array_slice($lines, $skip_until)) : $content;
    }

    // ── Local (native WP) publishing ──────────────────────────────────────────

    private function publication_result_from_ledger(array $row, string $title, array $image_errors, int $featured_media_id): array
    {
        return [
            'post_id'           => (int) ($row['post_id'] ?? 0),
            'post_url'          => (string) ($row['post_url'] ?? ''),
            'edit_url'          => (string) ($row['edit_url'] ?? ''),
            'title'             => $title,
            'image_errors'      => $image_errors,
            'images_count'      => $featured_media_id ? 1 : 0,
            'publication_state' => 'existing',
            'recovered'         => true,
            'idempotency_key'   => (string) ($row['idempotency_key'] ?? ''),
        ];
    }

    private function finish_local(
        string $title,
        string $content,
        string $status,
        string $slug,
        string $date,
        ?int   $cat_id,
        int    $featured_media_id,
        string $featured_media_url,
        array  $seo,
        bool   $is_update,
        int    $update_id,
        array  $image_errors,
        string $idempotency_key = '',
        int $production_id = 0,
        string $claim_token = '',
        string $schema_markup = ''
    ): array {
        $content = Costudio_Publication_Recovery::strip_markers($content);
        $content = Costudio_Publisher_Schema::strip_json_ld($content);

        if ($is_update && $update_id) {
            $post_arr = [
                'ID'           => $update_id,
                'post_title'   => $title,
                'post_content' => $content,
                'post_excerpt' => $seo['excerpt'] ?? '',
                'post_status'  => $status,
            ];
            $post_id = wp_update_post(wp_slash($post_arr), true);
        } else {
            $post_arr = [
                'post_title'   => $title,
                'post_content' => $content,
                'post_excerpt' => $seo['excerpt'] ?? '',
                'post_status'  => $status,
                'post_name'    => $slug,
            ];
            if ($date) {
                $post_arr['post_date']     = get_date_from_gmt(gmdate('Y-m-d H:i:s', strtotime($date)));
                $post_arr['post_date_gmt'] = gmdate('Y-m-d H:i:s', strtotime($date));
            }
            if ($cat_id) {
                $post_arr['post_category'] = [$cat_id];
            }
            $post_id = wp_insert_post(wp_slash($post_arr), true);
        }

        if (is_wp_error($post_id)) {
            throw new Exception($post_id->get_error_message());
        }

        if ('' !== $idempotency_key) {
            update_post_meta($post_id, '_costudio_idempotency_key', $idempotency_key);
        }
        if ($production_id > 0 && '' !== $claim_token) {
            $checkpoint_url  = (string) get_permalink($post_id);
            $checkpoint_edit = admin_url("post.php?post={$post_id}&action=edit");
            if (! Costudio_Publication_Ledger::checkpoint($production_id, $claim_token, (int) $post_id, $checkpoint_url, $checkpoint_edit)) {
                throw new Exception(__('The local post was created but its publication checkpoint could not be saved.', 'contentosapp-studio'));
            }
            Costudio_Publication_Ledger::mark_finalizing($production_id, $claim_token);
        }

        if ($featured_media_id) {
            set_post_thumbnail($post_id, $featured_media_id);
        }

        $runtime = [
            'image_url'      => $featured_media_url ?: ($featured_media_id ? (string) wp_get_attachment_url($featured_media_id) : ''),
            'date_published' => get_post_time('c', true, $post_id),
            'date_modified'  => get_post_modified_time('c', true, $post_id),
            'page_url'       => (string) get_permalink($post_id),
        ];
        $runtime_schema_markup = '';

        if ('' !== trim($schema_markup)) {
            $runtime_schema_markup = $this->apply_runtime_article_schema($schema_markup, $runtime);
            Costudio_Publisher_Schema::store_for_post($post_id, $runtime_schema_markup);
        }

        $runtime_content = $this->apply_runtime_article_schema($content, $runtime);

        if ($runtime_content !== $content) {
            $runtime_update = wp_update_post(
                wp_slash(
                    [
                        'ID'           => $post_id,
                        'post_content' => $runtime_content,
                    ]
                ),
                true
            );
            if (is_wp_error($runtime_update)) {
                throw new Exception($runtime_update->get_error_message());
            }
        }

        if ('' !== trim($runtime_schema_markup)) {
            $this->persist_local_schema_block($post_id, $runtime_schema_markup, $production_id);
        }

        update_post_meta($post_id, 'rank_math_focus_keyword', $seo['focus_kw']);
        update_post_meta($post_id, 'rank_math_title',         $seo['seo_title']);
        update_post_meta($post_id, 'rank_math_description',   $seo['seo_desc']);
        $post_url = (string) get_permalink($post_id);
        $edit_url = admin_url("post.php?post={$post_id}&action=edit");
        $post     = get_post($post_id);

        $result = [
            'post_id'      => $post_id,
            'post_url'     => $post_url ?: home_url("/?p={$post_id}"),
            'edit_url'     => $edit_url,
            'title'        => $post ? $post->post_title : $title,
            'image_errors' => $image_errors,
            'images_count' => $featured_media_id ? 1 : 0,
            'publication_state' => $is_update ? 'existing' : 'created',
            'recovered'     => $is_update,
            'idempotency_key' => $idempotency_key,
        ];

        $this->log_info(
            'publisher.publish.local_success',
            [
                'post_id'            => $post_id,
                'featured_media_id'  => $featured_media_id,
                'image_errors_count' => count($image_errors),
            ]
        );

        return $result;
    }

    private function persist_local_schema_block(int $post_id, string $schema_markup, int $production_id = 0): void
    {
        $schema_block = Costudio_Publisher_Schema::extract_json_ld($schema_markup);
        if ('' === trim($schema_block)) {
            $this->log_debug('publish.local_schema_block_skipped', [
                'post_id' => $post_id,
                'reason'  => 'empty_or_invalid_schema',
            ], $production_id);
            return;
        }

        $post = get_post($post_id);
        if (! $post) {
            throw new Exception(__('The post was created, but its final schema update failed. Retrying will reuse the existing post.', 'contentosapp-studio'));
        }

        $content = Costudio_Publication_Recovery::strip_markers((string) $post->post_content);
        $content = Costudio_Publisher_Schema::strip_json_ld($content);
        $next    = rtrim($content) . "\n\n" . trim($schema_block) . "\n";

        if ($next === (string) $post->post_content) {
            $this->log_debug('publish.local_schema_block_unchanged', [
                'post_id' => $post_id,
            ], $production_id);
            return;
        }

        $allow_schema_script = static function ($tags, $context) {
            if ('post' !== $context) {
                return $tags;
            }

            $tags = is_array($tags) ? $tags : [];
            $tags['script'] = [
                'type' => true,
            ];

            return $tags;
        };

        add_filter('wp_kses_allowed_html', $allow_schema_script, 10, 2);
        try {
            $updated = wp_update_post(
                wp_slash(
                    [
                        'ID'           => $post_id,
                        'post_content' => $next,
                    ]
                ),
                true
            );
        } finally {
            remove_filter('wp_kses_allowed_html', $allow_schema_script, 10);
        }

        if (is_wp_error($updated) || (int) $updated <= 0) {
            throw new Exception(__('The post was created, but its final schema update failed. Retrying will reuse the existing post.', 'contentosapp-studio'));
        }

        if (function_exists('clean_post_cache')) {
            clean_post_cache($post_id);
        }

        $this->log_debug('publish.local_schema_block_saved', [
            'post_id' => $post_id,
            'count'   => count(Costudio_Publisher_Schema::extract_payloads($schema_block)),
        ], $production_id);
    }

    private function update_remote_post_content(int $post_id, string $content, string $title = '', int $production_id = 0): bool
    {
        $data = [
            'content' => $content,
        ];

        if (! empty($title)) {
            $data['title'] = $title;
        }

        $url = costudio_safe_http_url("{$this->site_url}/wp-json/wp/v2/posts/{$post_id}");
        if ($url === '') {
            $this->log_error(
                'publisher.publish.remote_schema_update.unsafe_url',
                [
                    'production_id' => $production_id,
                    'post_id'       => $post_id,
                ],
                $production_id
            );
            return false;
        }

        $response = wp_safe_remote_request(
            $url,
            costudio_safe_remote_args([
                'method'  => 'PUT',
                'timeout' => 30,
                'headers' => [
                    'Authorization' => $this->auth,
                    'Content-Type'  => 'application/json',
                ],
                'body' => wp_json_encode($data),
            ])
        );

        if (is_wp_error($response)) {
            $this->log_error(
                'publisher.publish.remote_schema_update.wp_error',
                [
                    'production_id' => $production_id,
                    'post_id'       => $post_id,
                    'error'         => $response->get_error_message(),
                ],
                $production_id
            );
            return false;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = (string) wp_remote_retrieve_body($response);

        $this->log_debug(
            'publisher.publish.remote_schema_update.response',
            [
                'production_id' => $production_id,
                'post_id'       => $post_id,
                'code'          => $code,
                'snippet'       => substr($raw, 0, 180),
            ],
            $production_id
        );
        return in_array($code, [200, 201], true);
    }

    private function apply_runtime_article_schema(string $content, array $runtime): string
    {
        if (strpos($content, 'application/ld+json') === false) {
            return $content;
        }

        return preg_replace_callback(
            '/<script type="application\/ld\+json">(.*?)<\/script>/is',
            function (array $matches) use ($runtime) {
                $json = trim($matches[1]);
                $data = json_decode($json, true);

                if (! is_array($data)) {
                    return $matches[0];
                }

                if (! $this->apply_runtime_article_schema_payload($data, $runtime)) {
                    return $matches[0];
                }

                return "<script type=\"application/ld+json\">\n"
                    . wp_json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                    . "\n</script>";
            },
            $content
        ) ?: $content;
    }

    private function apply_runtime_article_schema_payload(array &$data, array $runtime): bool
    {
        $changed = false;

        if (isset($data['@graph']) && is_array($data['@graph'])) {
            foreach ($data['@graph'] as &$node) {
                if (is_array($node) && $this->apply_runtime_article_schema_node($node, $runtime)) {
                    $changed = true;
                }
            }
            unset($node);

            return $changed;
        }

        return $this->apply_runtime_article_schema_node($data, $runtime);
    }

    private function apply_runtime_article_schema_node(array &$node, array $runtime): bool
    {
        $changed = false;

        // WebPage: fill @id/url with the real permalink. The agent emits a
        // placeholder (URL_DA_PAGINA / PAGE_URL / URL_DE_LA_PAGINA — sometimes a
        // plausible-but-wrong guessed URL) that must never ship; the real
        // permalink, known only after the post exists, is authoritative.
        // NOTE: as of Item 2 the agent no longer emits WebPage/MedicalWebPage
        // nodes (page-level entities belong to the site's SEO layer); this branch
        // stays only for backward-compat with older productions that still carry one.
        if (! empty($runtime['page_url']) && $this->is_webpage_schema_payload($node)) {
            $node['@id'] = (string) $runtime['page_url'];
            $node['url'] = (string) $runtime['page_url'];
            $changed      = true;
        }

        if (! $this->is_article_schema_payload($node)) {
            return $changed;
        }

        // Link the Article to its page via mainEntityOfPage carrying the real
        // permalink. Page-level entities (WebPage/Organization/WebSite/Breadcrumb)
        // are owned by the site's SEO layer (Rank Math/Yoast); the agent no longer
        // emits a standalone WebPage node, so mainEntityOfPage is the Article's
        // authoritative tie to its page. Always a clean {"@id": permalink}
        // reference — any agent-sent @type/url/name is discarded so a stale or
        // guessed value can never ship. Skip WebPage variants (MedicalWebPage IS
        // the page). In the pre-create remote pass the permalink is unknown, so
        // mainEntityOfPage is REMOVED rather than shipped as a "PAGE_URL"
        // placeholder: if the direct-publish POST is interrupted before the
        // recovery PUT, a missing optional property is valid — a stuck placeholder
        // is not. The post-creation pass re-adds it with the real permalink.
        if (! $this->is_webpage_schema_payload($node)) {
            if (! empty($runtime['page_url'])) {
                $node['mainEntityOfPage'] = ['@id' => (string) $runtime['page_url']];
            } else {
                unset($node['mainEntityOfPage']);
            }
        }

        if (! empty($runtime['image_url'])) {
            $node['image'] = (string) $runtime['image_url'];
        } else {
            unset($node['image']);
        }

        if (! empty($runtime['date_published'])) {
            $node['datePublished'] = (string) $runtime['date_published'];
        }

        if (! empty($runtime['date_modified'])) {
            $node['dateModified'] = (string) $runtime['date_modified'];
        }

        return true;
    }

    private function is_article_schema_payload(array $data): bool
    {
        $type = $data['@type'] ?? '';
        $types = is_array($type) ? $type : [$type];

        foreach ($types as $candidate) {
            $candidate = (string) $candidate;
            if (
                stripos($candidate, 'article') !== false
                || stripos($candidate, 'posting') !== false
                || strcasecmp($candidate, 'MedicalWebPage') === 0
            ) {
                return true;
            }
        }

        return false;
    }

    private function is_webpage_schema_payload(array $data): bool
    {
        $type  = $data['@type'] ?? '';
        $types = is_array($type) ? $type : [$type];

        foreach ($types as $candidate) {
            if (
                strcasecmp((string) $candidate, 'WebPage') === 0
                || strcasecmp((string) $candidate, 'MedicalWebPage') === 0
            ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Rebuild the FAQPage schema's mainEntity from the article's VISIBLE FAQ so
     * the structured data can never diverge from what the reader sees. This kills
     * a recurring bug where the design agent (06) reworded answers or invented
     * specifics/sources inside the schema while the on-page FAQ said something
     * else — the visible FAQ is the single source of truth.
     *
     * Behaviour:
     *  - Parses Q&A pairs from the FAQ H2 section of the final (Gutenberg) content.
     *  - If ≥4 real pairs exist, replaces FAQPage.mainEntity with exactly those
     *    (verbatim question + verbatim answer).
     *  - If <4 real pairs exist, removes the FAQPage node entirely (Google only
     *    values FAQ schema with ≥4 real Q&As) and drops a <script> that held only
     *    the FAQPage.
     *  - If the FAQ section can't be located at all, the FAQPage is removed too
     *    (absence of visible evidence beats agent-generated content — a FAQPage
     *    without a matching visible FAQ violates Google's guidelines).
     *
     * Runs before both the local and remote publish branches, so the fix is
     * path-agnostic.
     */
    private function sync_faq_schema_to_visible(string $schema_markup, string $content, int $production_id = 0): string
    {
        if ('' === trim($schema_markup) || stripos($schema_markup, 'FAQPage') === false) {
            return $schema_markup;
        }

        // Visible FAQ is the source of truth: if the section is absent (null) or
        // has <4 real pairs, the FAQPage must go — a FAQPage without a matching
        // visible FAQ violates Google's structured-data guidelines, and the FAQ
        // rich result is deprecated anyway, so removing it costs nothing.
        $qas    = $this->extract_visible_faq($content);
        $qas    = is_array($qas) ? $qas : array();
        $enough = count($qas) >= 4;

        $result = preg_replace_callback(
            '/<script type="application\/ld\+json">(.*?)<\/script>/is',
            function (array $matches) use ($qas, $enough) {
                if (stripos($matches[1], 'FAQPage') === false) {
                    return $matches[0]; // preserve non-FAQ scripts byte-for-byte.
                }
                $data = json_decode(trim($matches[1]), true);
                if (! is_array($data)) {
                    return $matches[0];
                }
                $kept = $this->rewrite_faq_in_schema_data($data, $qas, $enough);
                if ($kept === null) {
                    return ''; // script held only a (now-removed) FAQPage.
                }
                return '<script type="application/ld+json">'
                    . wp_json_encode($kept, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                    . '</script>';
            },
            $schema_markup
        );

        if (! is_string($result)) {
            return $schema_markup;
        }

        // Collapse a wp:html wrapper left empty after a FAQ-only script was dropped.
        $result = preg_replace('/<!--\s*wp:html\s*-->\s*<!--\s*\/wp:html\s*-->/i', '', $result) ?? $result;

        if (function_exists('costudio_log_debug')) {
            costudio_log_debug('publisher.faq_schema_synced', [
                'production_id' => $production_id,
                'visible_qas'   => count($qas),
                'kept'          => $enough,
            ]);
        }

        return $result;
    }

    /**
     * Apply the visible-FAQ rewrite to one decoded schema payload. Returns the
     * (possibly mutated) payload, or null when the payload should be dropped
     * entirely (a standalone FAQPage removed for having <4 real Q&As, or a
     * @graph left empty).
     */
    private function rewrite_faq_in_schema_data(array $data, array $qas, bool $enough): ?array
    {
        $is_faq = static function ($node): bool {
            if (! is_array($node)) {
                return false;
            }
            $type  = $node['@type'] ?? '';
            $types = is_array($type) ? $type : [$type];
            foreach ($types as $t) {
                if (strcasecmp((string) $t, 'FAQPage') === 0) {
                    return true;
                }
            }
            return false;
        };

        $main_entity = array_map(
            static function (array $qa): array {
                return [
                    '@type'          => 'Question',
                    'name'           => $qa['q'],
                    'acceptedAnswer' => ['@type' => 'Answer', 'text' => $qa['a']],
                ];
            },
            $qas
        );

        if (isset($data['@graph']) && is_array($data['@graph'])) {
            $new_graph = [];
            foreach ($data['@graph'] as $node) {
                if ($is_faq($node)) {
                    if ($enough) {
                        $node['mainEntity'] = $main_entity;
                        $new_graph[]        = $node;
                    }
                    continue; // drop when not enough Q&As.
                }
                $new_graph[] = $node;
            }
            if (empty($new_graph)) {
                return null;
            }
            $data['@graph'] = array_values($new_graph);
            return $data;
        }

        if ($is_faq($data)) {
            if (! $enough) {
                return null;
            }
            $data['mainEntity'] = $main_entity;
            return $data;
        }

        return $data;
    }

    /**
     * Extract Q&A pairs from the article's visible FAQ section.
     *
     * @return array<int,array{q:string,a:string}>|null  Ordered pairs (possibly
     *         empty), or null when no visible FAQ H2 section exists.
     */
    private function extract_visible_faq(string $content): ?array
    {
        if ('' === trim($content) || ! class_exists('\DOMDocument')) {
            return null;
        }

        $dom        = new \DOMDocument();
        $prev_state = libxml_use_internal_errors(true);
        $loaded     = $dom->loadHTML(
            '<?xml encoding="UTF-8">' . $content,
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();
        libxml_use_internal_errors($prev_state);
        if (! $loaded) {
            return null;
        }

        // Locate the FAQ H2.
        $faq_h2 = null;
        foreach ($dom->getElementsByTagName('h2') as $h2) {
            $text = trim((string) preg_replace('/\s+/u', ' ', (string) $h2->textContent));
            if ($this->is_faq_heading($text)) {
                $faq_h2 = $h2;
                break;
            }
        }
        if ($faq_h2 === null) {
            return null; // no visible FAQ section.
        }

        // Walk siblings AT THE FAQ HEADING'S LEVEL: h3 = question, subsequent
        // block elements = answer, until the next h2. Sibling traversal (not a
        // flat `//p | //ul` XPath) avoids double-counting a container's
        // descendants (e.g. a <p> inside a <ul>) and never mis-reads an <h3>
        // nested inside an answer as a new question; <div>-wrapped answers are
        // captured because they are siblings too.
        $answer_tags = array('p' => true, 'ul' => true, 'ol' => true, 'blockquote' => true, 'div' => true);

        $q      = null;
        $answer = array();
        $qas    = array();

        $flush = static function () use (&$q, &$answer, &$qas): void {
            if ($q !== null && $q !== '') {
                $a = trim(implode(' ', $answer));
                if ($a !== '') {
                    $qas[] = array('q' => $q, 'a' => $a);
                }
            }
            $q      = null;
            $answer = array();
        };

        for ($node = $faq_h2->nextSibling; $node !== null; $node = $node->nextSibling) {
            if ($node->nodeType !== XML_ELEMENT_NODE) {
                continue; // skip comment / whitespace-text nodes between blocks.
            }
            $tag = strtolower($node->nodeName);
            if ($tag === 'h2') {
                break; // the next section ends the FAQ.
            }
            $text = trim((string) preg_replace('/\s+/u', ' ', (string) $node->textContent));
            if ($tag === 'h3') {
                $flush();
                $q = $text;
            } elseif ($q !== null && isset($answer_tags[$tag])) {
                if ($tag === 'ul' || $tag === 'ol') {
                    // Join list items readably so the schema answer isn't a mashed
                    // run of concatenated items ("alfabeta" → "alfa; beta").
                    $items = array();
                    foreach ($node->getElementsByTagName('li') as $li) {
                        $li_text = trim((string) preg_replace('/\s+/u', ' ', (string) $li->textContent));
                        if ($li_text !== '') {
                            $items[] = $li_text;
                        }
                    }
                    if (! empty($items)) {
                        $answer[] = implode('; ', $items);
                    }
                } elseif ($text !== '') {
                    $answer[] = $text;
                }
            }
        }
        $flush();

        return $qas;
    }

    /**
     * Whether an H2 heading text denotes the FAQ section, across PT/EN/ES and
     * their common variants. Conservative: matches known titles or short headings
     * that unambiguously signal a FAQ, so body headings that merely contain the
     * word "questions"/"perguntas" are not misread as the FAQ.
     */
    private function is_faq_heading(string $text): bool
    {
        $norm = $this->normalize_heading_text($text);
        if ($norm === '') {
            return false;
        }

        $exact = [
            'faq', 'faqs',
            'perguntas frequentes', 'perguntas mais frequentes', 'perguntas e respostas',
            'duvidas frequentes', 'duvidas comuns',
            'frequently asked questions', 'common questions', 'questions and answers',
            'preguntas frecuentes', 'preguntas mas frecuentes', 'preguntas y respuestas',
            'dudas frecuentes',
        ];
        if (in_array($norm, $exact, true)) {
            return true;
        }

        $word_count = str_word_count($norm);
        if ($word_count > 0 && $word_count <= 4) {
            foreach (['frequently asked', 'perguntas frequ', 'preguntas frec', 'duvidas frequ', 'dudas frec'] as $needle) {
                if (strpos($norm, $needle) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Lowercase + strip accents + keep alnum/spaces, for robust heading matching.
     */
    private function normalize_heading_text(string $text): string
    {
        $text = trim($text);
        // Fold accents FIRST, while the UTF-8 bytes are intact: a locale-sensitive
        // strtolower() (no mbstring) would otherwise corrupt multibyte characters
        // and defeat the map. Keys cover both cases so folding precedes lowercasing.
        $map = [
            'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a',
            'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
            'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i',
            'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
            'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u',
            'ç' => 'c', 'ñ' => 'n',
            'Á' => 'a', 'À' => 'a', 'Â' => 'a', 'Ã' => 'a', 'Ä' => 'a',
            'É' => 'e', 'Ê' => 'e', 'Í' => 'i', 'Ó' => 'o', 'Ô' => 'o',
            'Õ' => 'o', 'Ú' => 'u', 'Ü' => 'u', 'Ç' => 'c', 'Ñ' => 'n',
        ];
        $text = strtr($text, $map);
        $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
        $text = preg_replace('/[^a-z0-9 ]+/', '', $text) ?? $text;
        return trim((string) preg_replace('/\s+/', ' ', $text));
    }

    /**
     * Resolve a valid fallback datetime for schema sent in the first remote
     * write. The post-creation pass replaces it with WordPress' authoritative
     * dates whenever the REST response contains them.
     */
    private function resolve_schema_publish_iso(array $inputs): string
    {
        $mode = (string) ($inputs['publish_mode'] ?? 'draft');
        if ($mode === 'scheduled' && ! empty($inputs['publish_date'])) {
            $iso = $this->normalize_rest_datetime_to_iso8601((string) $inputs['publish_date']);
            if ($iso !== '') {
                return $iso;
            }
        }

        return gmdate('c');
    }

    private function normalize_rest_datetime_to_iso8601(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        if (preg_match('/(?:Z|[+\-]\d{2}:\d{2})$/', $value)) {
            return $value;
        }

        $timestamp = strtotime($value . ' UTC');
        if ($timestamp === false) {
            $timestamp = strtotime($value);
        }

        return $timestamp ? gmdate('c', $timestamp) : $value;
    }

    private function log_debug(string $message, array $context = [], ?int $production_id = null): void
    {
        $this->log('debug', $message, $context, $production_id);
    }

    private function log_info(string $message, array $context = [], ?int $production_id = null): void
    {
        $this->log('info', $message, $context, $production_id);
    }

    private function log_error(string $message, array $context = [], ?int $production_id = null): void
    {
        $this->log('error', $message, $context, $production_id);
    }

    private function log_warning(string $message, array $context = [], ?int $production_id = null): void
    {
        $this->log('warning', $message, $context, $production_id);
    }

    private function touch_publish_progress(int $production_id, string $message = '', string $stage = ''): void
    {
        if ($production_id <= 0) {
            return;
        }

        if (class_exists('Costudio_Production')) {
            Costudio_Production::update_heartbeat($production_id);
        }

		if (class_exists('Costudio_Publication_Jobs')) {
			if ($stage !== '') {
				Costudio_Publication_Jobs::stage($production_id, $stage, $message);
			}
			// Image-provider milestones do not always change stage, but they still
			// prove that the publication owner is alive and must renew its lease.
			Costudio_Publication_Jobs::heartbeat($production_id, PHP_SAPI === 'cli' ? Costudio_Publication_Jobs::CLI_LEASE_SECONDS : Costudio_Publication_Jobs::WEB_LEASE_SECONDS);
		}

        if ($message !== '' && class_exists('Costudio_Runner')) {
            Costudio_Runner::pipeline_log_append($production_id, $message, 'info');
        }
    }

    private function log(string $level, string $message, array $context = [], ?int $production_id = null): void
    {
        if ($level === 'debug' && function_exists('costudio_debug_enabled') && ! costudio_debug_enabled()) {
            return;
        }

        $payload = array_merge(
            [
                'site_key'   => $this->site_key,
                'site_url'   => $this->site_url,
                'local_mode' => $this->local_mode,
            ],
            $context
        );

        $encoded = function_exists('costudio_redact_log_value')
            ? costudio_redact_log_value($payload)
            : wp_json_encode($payload);
        if ( function_exists( 'costudio_debug_enabled' ) && costudio_debug_enabled() ) {
            costudio_log_debug( 'publisher.' . $message, $payload );
        }

        if (! class_exists('Costudio_Logger')) {
            return;
        }

        $safe_payload = json_decode($encoded, true);
        if (! is_array($safe_payload)) {
            $safe_payload = ['context' => $encoded];
        }

        if ($level === 'error') {
            Costudio_Logger::error($message, $safe_payload, $production_id);
        } elseif ($level === 'warning') {
            Costudio_Logger::warn($message, $safe_payload, $production_id);
        } elseif ($level === 'info') {
            Costudio_Logger::info($message, $safe_payload, $production_id);
        } else {
            Costudio_Logger::debug($message, $safe_payload, $production_id);
        }
    }

    private function get_or_create_category_local(string $name): ?int
    {
        if (empty($name)) {
            return null;
        }

        $term = get_term_by('name', $name, 'category');
        if ($term instanceof WP_Term) {
            return $term->term_id;
        }

        $result = wp_insert_term($name, 'category', ['slug' => sanitize_title($name)]);
        if (is_wp_error($result)) {
            // Term may already exist under a different slug — try by slug.
            $by_slug = get_term_by('slug', sanitize_title($name), 'category');
            return $by_slug instanceof WP_Term ? $by_slug->term_id : null;
        }

        return (int) $result['term_id'];
    }

    // ── Resumable image manifest (idempotent publishing) ──────────────────────

    /**
     * Generate+upload an image, OR reuse it if it was already produced in a
     * previous (interrupted) publish attempt for this production.
     *
     * The image manifest (`Costudio_Production::images_json`) is checkpointed
     * immediately after each successful upload. On a retry that follows a server
     * timeout, a `done` entry is returned WITHOUT calling the provider again —
     * so we never pay twice nor leave orphan attachments in the media library.
     *
     * Exceptions (plan-limited, provider errors) propagate to the caller, which
     * keeps the exact same handling it had before — a failed image is simply not
     * checkpointed, so a later retry re-attempts it (current behavior preserved).
     *
     * @param int                    $production_id
     * @param array                  $manifest      Passed by reference; mutated + persisted on success.
     * @param string                 $key           Stable image key (the [IMG:] marker filename, e.g. 'imagem1.webp').
     * @param string                 $prompt
     * @param string                 $alt
     * @param string                 $caption
     * @param string                 $seo_filename
     * @param Costudio_Image_Generator $generator
     * @return array{attachment_id:int,url:string} Same shape as generate_and_upload().
     */
    private function generate_or_reuse_image(
        int $production_id,
        array &$manifest,
        string $key,
        string $prompt,
        string $alt,
        string $caption,
        string $seo_filename,
        Costudio_Image_Generator $generator
    ): array {
        $provider_block = isset($manifest['provider_block']) && is_array($manifest['provider_block'])
            ? $manifest['provider_block']
            : [];
        if (! empty($provider_block)) {
            $blocked_at = (int) ($provider_block['blocked_at'] ?? 0);
            $is_active  = $blocked_at > 0 && (time() - $blocked_at) < self::IMAGE_PROVIDER_BLOCK_TTL;

            if ($is_active) {
                throw new Costudio_Image_Provider_Unavailable_Exception(
                    (string) ($provider_block['message'] ?? Costudio_Image_Generator::managed_provider_unavailable_message()),
                    (string) ($provider_block['reason'] ?? 'provider_billing')
                );
            }

            // A later manual reprocess gets a fresh provider attempt after the
            // short circuit-breaker window, without discarding completed images.
            unset($manifest['provider_block']);
            Costudio_Production::set_images_json($production_id, $manifest);
        }

        $existing = $manifest['items'][$key] ?? null;
        $status   = is_array($existing) ? (string) ($existing['status'] ?? '') : '';
        $attempts = is_array($existing) ? (int) ($existing['attempts'] ?? 0) : 0;

        // Reuse a finished image — the core idempotency win (no provider call).
        if ('done' === $status && ! empty($existing['attachment_id'])) {
            $this->log_info(
                'publisher.image.reused_from_manifest',
                [ 'production_id' => $production_id, 'key' => $key, 'attachment_id' => (int) $existing['attachment_id'] ],
                $production_id
            );
            return [
                'attachment_id' => (int) $existing['attachment_id'],
                'url'           => (string) ($existing['url'] ?? ''),
            ];
        }

        // Terminal non-success states: re-raise the matching exception so the
        // caller's existing handling applies (plan-limited keeps the [IMG:]
        // marker; failed drops it). This stops a permanently-failing/plan-limited
        // image from being regenerated on every chunked pass (no re-dispatch loop).
        if ('plan_limited' === $status) {
            throw new Costudio_Image_Plan_Limited_Exception('manifest_terminal');
        }
        if ('failed' === $status && $attempts >= self::MAX_IMAGE_ATTEMPTS) {
            throw new Exception((string) ($existing['error'] ?? 'image generation failed'));
        }

        // Chunked budget: generate at most ONE new image per publish pass; defer
        // the rest so the publish step re-dispatches (one HTTP request per image).
        if ($this->chunked_publish && $this->images_generated_this_pass >= 1) {
            $this->images_pending = true;
            throw new Costudio_Image_Deferred_Exception();
        }

        // Time budget: a provider call left unchecked can outlive the durable
        // worker's lease (360s under the web-fallback driver), leaving the job
        // claimed-but-dead with no checkpoint and no logged error until the
        // lease expires — then it repeats identically forever. Bail out BEFORE
        // starting an attempt unlikely to finish in time, and cap the HTTP
        // timeout of the attempt we do make to what safely remains (accounting
        // for post_with_retry()'s one automatic retry).
        if ($this->chunked_publish) {
            $remaining = self::IMAGE_PASS_SAFE_BUDGET_SECONDS - (microtime(true) - $this->pass_started_at);
            $per_attempt_timeout = (int) floor(($remaining - 2) / 2);
            if ($per_attempt_timeout < self::IMAGE_PASS_MIN_ATTEMPT_SECONDS) {
                $this->images_pending = true;
                throw new Costudio_Image_Deferred_Exception();
            }
            $generator->set_http_timeout_budget(min(150, $per_attempt_timeout));
        }

        // Count the ATTEMPT against the one-per-pass budget BEFORE calling the
        // provider — so a slow FAILED attempt (e.g. a timeout) also consumes the
        // pass and we never chain two slow generations in a single request.
        $this->images_generated_this_pass++;

        // Any generation ATTEMPT this pass forces a re-dispatch — even if this is
        // the LAST expected image (no later image to throw Deferred). This
        // guarantees a FINAL zero-generation pass before the ledger/post, and
        // ensures a failed last image is retried up to MAX_IMAGE_ATTEMPTS instead
        // of publishing early. The next pass reuses done items / skips terminals,
        // makes no attempt, and proceeds to publish.
        if ($this->chunked_publish) {
            $this->images_pending = true;
        }

        // Generate now (new image, or a prior 'failed' still under the attempt cap → retry).
        $result = $generator->generate_and_upload($prompt, $alt, $caption, $seo_filename);

        $manifest['items'][$key] = [
            'status'        => 'done',
            'role'          => ('imagem1.webp' === $key) ? 'cover' : 'inline',
            'attachment_id' => (int) ($result['attachment_id'] ?? 0),
            'url'           => (string) ($result['url'] ?? ''),
            'alt'           => $alt,
            'caption'       => $caption,
            'seo_filename'  => $seo_filename,
        ];
        Costudio_Production::set_images_json($production_id, $manifest);

        return $result;
    }

    /**
     * Persist one production-scoped provider circuit breaker and expose only one
     * friendly warning, even when several image placeholders remain.
     */
    private function record_image_provider_unavailable(
        int $production_id,
        array &$manifest,
        Costudio_Image_Provider_Unavailable_Exception $exception,
        array &$image_errors
    ): void {
        $already_blocked = ! empty($manifest['provider_block']);
        $manifest['provider_block'] = [
            'reason'     => $exception->get_reason(),
            'message'    => $exception->getMessage(),
            'blocked_at' => time(),
        ];
        Costudio_Production::set_images_json($production_id, $manifest);

        if (! $this->image_provider_warning_recorded) {
            $image_errors[] = $exception->getMessage();
            $this->image_provider_warning_recorded = true;
        }

        if (! $already_blocked) {
            $this->log_error(
                'publisher.publish.image_provider_unavailable',
                [
                    'production_id' => $production_id,
                    'reason'        => $exception->get_reason(),
                    'remaining_calls_skipped' => true,
                ],
                $production_id
            );
        }
    }

    /**
     * Record a non-success terminal/attempt state for an image in the manifest.
     * `plan_limited` is terminal immediately; `failed` becomes terminal once
     * `attempts` reaches MAX_IMAGE_ATTEMPTS (until then it is retried next pass).
     *
     * @param int    $production_id
     * @param array  $manifest  Passed by reference; mutated + persisted.
     * @param string $key
     * @param string $status    'plan_limited' | 'failed'
     * @param string $error
     */
    private function mark_image_terminal(int $production_id, array &$manifest, string $key, string $status, string $error = ''): void
    {
        $prev     = isset($manifest['items'][$key]) && is_array($manifest['items'][$key]) ? $manifest['items'][$key] : [];
        $attempts = (int) ($prev['attempts'] ?? 0) + 1;

        $entry = [
            'status'   => $status,
            'role'     => ('imagem1.webp' === $key) ? 'cover' : 'inline',
            'attempts' => $attempts,
        ];
        if ('' !== $error) {
            $entry['error'] = mb_substr($error, 0, 300);
        }

        $manifest['items'][$key] = array_merge($prev, $entry);
        Costudio_Production::set_images_json($production_id, $manifest);
    }

    // ── Image prompt extraction ───────────────────────────────────────────────

    private function extract_image_prompts(string $design): array
    {
        return Costudio_Publisher_Image_Prompts::extract(
            $design,
            function (string $snippet): void {
                $this->log_debug(
                    'publisher.publish.image_json_not_found',
                    ['design_snippet' => $snippet]
                );
            }
        );
    }

    private function recover_cover_prompt(
        array $all_prompts,
        string $seo_title,
        string $description,
        string $focus_kw,
        array $inputs,
        int $production_id
    ): array {
        foreach ($all_prompts as $img) {
            if (! empty($img['prompt'])) {
                $cover = $img;
                $cover['filename'] = 'imagem1.webp';
                $cover['alt'] = ! empty($cover['alt']) ? (string) $cover['alt'] : $this->fallback_cover_alt($seo_title, $focus_kw, $inputs);
                $cover['caption'] = ! empty($cover['caption']) ? (string) $cover['caption'] : '';

                $this->touch_publish_progress($production_id, __('Publicação: prompt da imagem destacada recuperado da saída do Designer.', 'contentosapp-studio'));
                $this->log_info(
                    'publisher.publish.cover_image.prompt_promoted',
                    [
                        'production_id'   => $production_id,
                        'source_filename' => (string) ($img['filename'] ?? ''),
                        'prompts_found'   => count($all_prompts),
                    ],
                    $production_id
                );

                return $cover;
            }
        }

        $fallback = $this->fallback_cover_prompt($seo_title, $description, $focus_kw, $inputs);
        $this->touch_publish_progress($production_id, __('Publicação: prompt da imagem destacada ausente; usando fallback seguro.', 'contentosapp-studio'));
        $this->log_info(
            'publisher.publish.cover_image.prompt_fallback',
            [
                'production_id' => $production_id,
                'title_len'     => strlen($seo_title),
                'desc_len'      => strlen($description),
                'keyword_len'   => strlen($focus_kw),
            ],
            $production_id
        );

        return $fallback;
    }

    private function fallback_cover_prompt(string $seo_title, string $description, string $focus_kw, array $inputs): array
    {
        $topic = trim($focus_kw);
        if ($topic === '') {
            $topic = trim($seo_title);
        }
        if ($topic === '') {
            $topic = trim((string) ($inputs['keyword'] ?? 'the article topic'));
        }

        $context = trim($description);
        if ($context === '') {
            $context = trim((string) ($inputs['angulo'] ?? $inputs['categoria'] ?? $inputs['nicho'] ?? ''));
        }

        $prompt = sprintf(
            'Create a high-quality editorial featured image for an article about "%s". Context: %s. Use a realistic, polished 16:9 composition with natural lighting, a clear main subject, and a professional publication style. Do not include text, logos, watermarks, UI screenshots, distorted hands, or brand marks. The image should visually communicate the core idea of the article and feel trustworthy for a WordPress editorial post.',
            $topic,
            $context !== '' ? $context : 'general educational content'
        );

        return [
            'filename' => 'imagem1.webp',
            'prompt'   => $prompt,
            'alt'      => $this->fallback_cover_alt($seo_title, $focus_kw, $inputs),
            'caption'  => '',
        ];
    }

    private function fallback_cover_alt(string $seo_title, string $focus_kw, array $inputs): string
    {
        $alt = trim($focus_kw);
        if ($alt === '') {
            $alt = trim($seo_title);
        }
        if ($alt === '') {
            $alt = trim((string) ($inputs['keyword'] ?? __('Article featured image', 'contentosapp-studio')));
        }

        $alt = preg_replace('/\s+/', ' ', $alt);

        return function_exists('mb_substr') ? mb_substr($alt, 0, 140) : substr($alt, 0, 140);
    }

    /**
     * Remove LLM editorial disclaimers ("Nota:", placeholders/CMS, etc.) before
     * reference rendering so tokens are not expanded into stray HTML.
     */
    private function strip_editorial_disclaimers(string $content): string
    {
        return Costudio_Publisher_Content_Normalizer::strip_editorial_disclaimers($content);
    }

    /**
     * Mix accent with white (10/90) — same rule as Costudio_Pipeline::get_soft_color().
     */
    private function compute_soft_background_hex(string $hex): string
    {
        return Costudio_Publisher_Content_Normalizer::soft_background_hex($hex);
    }

    /**
     * Whitelist of link hosts allowed in the body when the production has NO
     * grounded sources (web search off / returned nothing). Drives the
     * ungrounded-external-link strip.
     *
     * Returns null when the production HAS grounded sources — in that case the
     * {{ref:N}} validation already governs external links, so nothing is
     * stripped. Otherwise returns the hosts that may keep their links:
     *   - the destination site (site_key);
     *   - user-selected internal links;
     *   - user-provided external links (always preserved).
     * Anything external outside this set is an LLM hallucination and is stripped.
     *
     * @return array<int,string>|null
     */
    private function allowed_link_hosts_when_ungrounded(int $production_id): ?array
    {
        if ($production_id <= 0 || ! class_exists('Costudio_Production')) {
            return null;
        }
        $prod = Costudio_Production::get_by_id($production_id);
        if (! $prod) {
            return null;
        }

        // Grounded sources present → Cenário A governs; do not strip.
        if (! empty($prod->sources_json)) {
            $decoded = json_decode((string) $prod->sources_json, true);
            if (is_array($decoded) && ! empty($decoded)) {
                return null;
            }
        }

        $hosts = array();

        // Destination site host.
        if (! empty($prod->site_key) && function_exists('costudio_sites')) {
            $repo = costudio_sites();
            if ($repo) {
                $site = $repo->get((string) $prod->site_key);
                if (! empty($site['url'])) {
                    $hosts[] = (string) $site['url'];
                }
            }
        }

        // User-selected internal links + user-provided external links.
        if (! empty($prod->links_internos) && class_exists('Costudio_Pipeline')) {
            $parsed = Costudio_Pipeline::parse_links_internos((string) $prod->links_internos);
            foreach (array('internal', 'external') as $bucket) {
                foreach ((array) ($parsed[$bucket] ?? array()) as $item) {
                    if (! empty($item['url'])) {
                        $hosts[] = (string) $item['url'];
                    }
                }
            }
        }

        return $hosts;
    }

    /**
     * @return array{accent:string,soft:string}
     */
    private function resolve_tldr_accent_soft(int $production_id): array
    {
        $accent = Costudio_References_Renderer::DEFAULT_ACCENT_COLOR;
        $site_key = '';

        if ($production_id > 0 && class_exists('Costudio_Production')) {
            $prod = Costudio_Production::get_by_id($production_id);
            if ($prod) {
                $site_key = (string) $prod->site_key;
            }
        }
        if ($site_key === '' && $this->site_key !== '' && $this->site_key !== self::LOCAL_SITE_KEY) {
            $site_key = $this->site_key;
        }

        if ($site_key !== '' && function_exists('costudio_sites')) {
            $repo = costudio_sites();
            if ($repo) {
                $cfg = $repo->get($site_key);
                if (! empty($cfg['color']) && is_string($cfg['color'])) {
                    $c = trim($cfg['color']);
                    if ($c !== '' && preg_match('/^#[0-9a-f]{3,8}$/i', $c)) {
                        $accent = $c;
                    }
                }
            }
        }

        return [
            'accent' => $accent,
            'soft'   => $this->compute_soft_background_hex($accent),
        ];
    }

    private function looks_like_tldr_blockquote(string $block): bool
    {
        if (! preg_match('/^[ \t]*>[ \t]*##\s+[^\n]+/m', $block)) {
            return false;
        }

        $lines = preg_split('/\r?\n/', $block);
        $body_lines = 0;
        foreach ($lines as $line) {
            if (! preg_match('/^[ \t]*>(.*)/u', $line, $mm)) {
                return false;
            }
            $inner = trim($mm[1]);
            if ($inner === '') {
                continue;
            }
            if (preg_match('/^##\s+/u', $inner)) {
                continue;
            }
            if (mb_strlen($inner, 'UTF-8') >= 2) {
                $body_lines++;
            }
        }

        return $body_lines >= 1;
    }

    // ── Comparison marker processing ──────────────────────────────────────────

    /**
     * Convert comparison-specific markers to Gutenberg blocks before Parsedown.
     *
     * Handles:
     *   [FAVORITOS_BOX]          → styled wp:html highlight box (top 3 products from inputs)
     *   [COMPRAR: url | text]    → native wp:button with affiliate rel attributes
     *   [PROS]...[/PROS]         → green wp:html styled box
     *   [CONTRAS]...[/CONTRAS]   → red wp:html styled box
     *
     * Each converted block is replaced by a ___CMP_BLOCK_N___ placeholder so
     * Parsedown never sees raw HTML. Placeholders are restored in publish()
     * after markdown_to_gutenberg() runs.
     *
     * @param string $content    Raw Markdown from Agent 04.
     * @param array  $inputs     Production inputs (comparison_items, site_color).
     * @param array  &$cmp_blocks Passed-by-reference map of placeholder → block HTML.
     * @return string Content with markers replaced by placeholders.
     */
    private function process_comparison_markers( string $content, array $inputs, array &$cmp_blocks ): string {

        // Reader-facing labels follow the ARTICLE's content locale (not the
        // admin locale) — see comparison_label() for why __() is unsafe here.
        $locale     = $this->resolve_content_locale( $inputs );
        $pros_label = self::comparison_label( $locale, 'pros' );
        $cons_label = self::comparison_label( $locale, 'cons' );
        $buy_labels = array(
            'amazon'  => self::comparison_label( $locale, 'buy_amazon' ),
            'mercado' => self::comparison_label( $locale, 'buy_mercado' ),
            'generic' => self::comparison_label( $locale, 'buy_generic' ),
        );

        // ── [FAVORITOS_BOX] → "Our Picks" highlight box ──────────────────────
        $content = preg_replace_callback(
            '/\[FAVORITOS_BOX\]/i',
            function ( array $m ) use ( $inputs, &$cmp_blocks ) {
                $block = $this->build_favoritos_box( $inputs );
                if ( '' === $block ) {
                    return '';
                }
                $placeholder              = '<!-- ___CMP_BLOCK_' . count( $cmp_blocks ) . '___ -->';
                $cmp_blocks[$placeholder] = $block;
                return "\n\n{$placeholder}\n\n";
            },
            $content
        );

        // ── [COMPRAR: url | Button text] → wp:button ─────────────────────────
        $content = preg_replace_callback(
            '/\[COMPRAR:\s*([^\|\]]+?)\s*\|\s*([^\]]*?)\s*\]/i',
            static function ( array $m ) use ( &$cmp_blocks, $buy_labels ) {
                $raw_url = trim( $m[1] );
                $text    = trim( $m[2] );
                $url     = esc_url( $raw_url );

                if ( '' === $url ) {
                    return '';
                }

                // Auto-detect button label when the agent left it empty. Labels are
                // localized to the article's content locale (see comparison_label()).
                if ( '' === $text ) {
                    if ( false !== stripos( $raw_url, 'amazon.com' ) || false !== stripos( $raw_url, 'amzn.to' ) ) {
                        $text = $buy_labels['amazon'];
                    } elseif ( false !== stripos( $raw_url, 'mercadolivre.com' ) ) {
                        $text = $buy_labels['mercado'];
                    } else {
                        $text = $buy_labels['generic'];
                    }
                }

                $block = "<!-- wp:button {\"className\":\"fos-cta-buy\"} -->\n"
                    . '<div class="wp-block-button fos-cta-buy">'
                    . '<a class="wp-block-button__link wp-element-button"'
                    . ' href="' . $url . '"'
                    . ' target="_blank"'
                    . ' rel="nofollow noopener sponsored">'
                    . esc_html( $text )
                    . '</a>'
                    . "</div>\n"
                    . '<!-- /wp:button -->';

                $placeholder              = '<!-- ___CMP_BLOCK_' . count( $cmp_blocks ) . '___ -->';
                $cmp_blocks[$placeholder] = $block;
                return "\n\n{$placeholder}\n\n";
            },
            $content
        );

        // ── [PROS]...[/PROS] → green styled box ──────────────────────────────
        $content = preg_replace_callback(
            '/\[PROS\](.*?)\[\/PROS\]/is',
            static function ( array $m ) use ( &$cmp_blocks, $pros_label ) {
                $items   = self::parse_comparison_list_items( trim( $m[1] ) );
                $li_html = '';
                foreach ( $items as $item ) {
                    $li_html .= '<li style="margin:4px 0">' . esc_html( $item ) . '</li>';
                }
                if ( '' === $li_html ) {
                    return $m[0];
                }

                $block = "<!-- wp:html -->\n"
                    . '<div class="fos-pros" style="background:#f0fdf4;border-left:4px solid #22c55e;padding:14px 18px;border-radius:6px;margin:16px 0">'
                    . '<p style="font-weight:700;color:#15803d;margin:0 0 8px;font-size:14px">✅ ' . esc_html( $pros_label ) . '</p>'
                    . '<ul style="margin:0;padding-left:20px;color:#1a2e1a">' . $li_html . '</ul>'
                    . "</div>\n"
                    . '<!-- /wp:html -->';

                $placeholder              = '<!-- ___CMP_BLOCK_' . count( $cmp_blocks ) . '___ -->';
                $cmp_blocks[$placeholder] = $block;
                return "\n\n{$placeholder}\n\n";
            },
            $content
        );

        // ── [CONTRAS]...[/CONTRAS] → red styled box ──────────────────────────
        $content = preg_replace_callback(
            '/\[CONTRAS\](.*?)\[\/CONTRAS\]/is',
            static function ( array $m ) use ( &$cmp_blocks, $cons_label ) {
                $items   = self::parse_comparison_list_items( trim( $m[1] ) );
                $li_html = '';
                foreach ( $items as $item ) {
                    $li_html .= '<li style="margin:4px 0">' . esc_html( $item ) . '</li>';
                }
                if ( '' === $li_html ) {
                    return $m[0];
                }

                $block = "<!-- wp:html -->\n"
                    . '<div class="fos-contras" style="background:#fef2f2;border-left:4px solid #ef4444;padding:14px 18px;border-radius:6px;margin:16px 0">'
                    . '<p style="font-weight:700;color:#dc2626;margin:0 0 8px;font-size:14px">❌ ' . esc_html( $cons_label ) . '</p>'
                    . '<ul style="margin:0;padding-left:20px;color:#2e1a1a">' . $li_html . '</ul>'
                    . "</div>\n"
                    . '<!-- /wp:html -->';

                $placeholder              = '<!-- ___CMP_BLOCK_' . count( $cmp_blocks ) . '___ -->';
                $cmp_blocks[$placeholder] = $block;
                return "\n\n{$placeholder}\n\n";
            },
            $content
        );

        return $content;
    }

    /**
     * Build the "Our Picks" highlight box from the production's comparison_items.
     * Title and button labels are localized to the production's content locale.
     * Shows the first 3 items (or fewer). Generated entirely in PHP so the box is
     * always structurally correct, regardless of agent output quality.
     */
    private function build_favoritos_box( array $inputs ): string {
        $items      = $inputs['comparison_items'] ?? [];
        $site_color = ! empty( $inputs['site_color'] ) ? $inputs['site_color'] : '#3b82f6';
        $top        = array_slice( $items, 0, 3 );
        $locale     = $this->resolve_content_locale( $inputs );

        if ( empty( $top ) ) {
            return '';
        }

        $cards = '';
        foreach ( $top as $item ) {
            $label = esc_html( trim( $item['label'] ?? '' ) );
            $url   = esc_url( trim( $item['url'] ?? '' ) );

            if ( '' === $label ) {
                continue;
            }

            // Auto-detect button text by domain.
            $raw_url = trim( $item['url'] ?? '' );
            if ( false !== stripos( $raw_url, 'amazon.com' ) || false !== stripos( $raw_url, 'amzn.to' ) ) {
                $btn_text = esc_html( self::comparison_label( $locale, 'buy_amazon' ) );
            } elseif ( false !== stripos( $raw_url, 'mercadolivre.com' ) ) {
                $btn_text = esc_html( self::comparison_label( $locale, 'buy_mercado' ) );
            } else {
                $btn_text = esc_html( self::comparison_label( $locale, 'buy_generic' ) );
            }

            $link_open  = ! empty( $url )
                ? '<a href="' . $url . '" target="_blank" rel="nofollow noopener sponsored"'
                    . ' style="display:inline-block;background:' . esc_attr( $site_color ) . ';color:#fff;text-decoration:none;padding:8px 14px;border-radius:6px;font-size:13px;font-weight:600;margin-top:10px">'
                : '';
            $link_close = ! empty( $url ) ? '</a>' : '';

            $cards .= '<div style="flex:1;min-width:180px;background:#fff;border-radius:8px;padding:14px 16px;box-shadow:0 1px 4px rgba(0,0,0,.06)">'
                . '<p style="font-weight:600;font-size:14px;margin:0 0 4px;color:#1a1a2e">' . $label . '</p>'
                . ( ! empty( $url ) ? $link_open . $btn_text . $link_close : '' )
                . '</div>';
        }

        if ( '' === $cards ) {
            return '';
        }

        $html = "<!-- wp:html -->\n"
            . '<div class="fos-favoritos" style="background:#f8fafc;border:1px solid ' . esc_attr( $site_color ) . ';border-radius:10px;padding:20px 24px;margin:28px 0">'
            . '<p style="font-weight:700;font-size:16px;margin:0 0 16px;color:#1a1a2e">⭐ ' . esc_html( self::comparison_label( $locale, 'favorites' ) ) . '</p>'
            . '<div style="display:flex;gap:14px;flex-wrap:wrap">'
            . $cards
            . '</div>'
            . "</div>\n"
            . '<!-- /wp:html -->';

        return $html;
    }

    /**
     * Parse bullet list items from Markdown text (-, *, +, or numbered lists).
     * Used internally by process_comparison_markers().
     */
    private static function parse_comparison_list_items( string $md ): array {
        $lines = preg_split( "/\r\n|\r|\n/", $md );
        $items = [];
        foreach ( $lines as $line ) {
            $line = trim( $line );
            if ( preg_match( '/^[\-\*\+]\s+(.+)$/', $line, $m ) ) {
                $items[] = trim( $m[1] );
            } elseif ( preg_match( '/^\d+[\.\)]\s+(.+)$/', $line, $m ) ) {
                $items[] = trim( $m[1] );
            }
        }
        return $items;
    }

    /**
     * Resolve the production's CONTENT locale (en_US|es_ES|pt_BR) for the
     * PHP-rendered comparison labels. Prefers the per-run snapshot set on
     * $inputs by the pipeline; falls back to the Content Language setting;
     * then en_US. Never uses the WordPress admin/site locale.
     */
    private function resolve_content_locale( array $inputs ): string {
        $loc = (string) ( $inputs['lang_code'] ?? '' );
        if ( '' === $loc && class_exists( 'Costudio_Admin' ) ) {
            $loc = (string) Costudio_Admin::get_default_content_language();
        }
        return in_array( $loc, array( 'en_US', 'es_ES', 'pt_BR' ), true ) ? $loc : 'en_US';
    }

    /**
     * Reader-facing labels for the PHP-rendered comparison blocks (Our Picks
     * box title, Pros/Cons box titles, fallback buy-button text), keyed by the
     * production's CONTENT locale.
     *
     * These intentionally do NOT go through __()/the .po files: gettext resolves
     * to the WordPress admin/site locale, but a comparison article can be written
     * in a different language than the admin UI (e.g. admin pt_BR, article en_US).
     * The label must follow the ARTICLE's language, so it is keyed off the
     * production content locale instead.
     *
     * @param string $locale Production content locale (en_US|es_ES|pt_BR).
     * @param string $key     Label key.
     */
    private static function comparison_label( string $locale, string $key ): string {
        $labels = array(
            'en_US' => array(
                'favorites'   => 'Our Picks',
                'pros'        => 'Pros',
                'cons'        => 'Cons',
                'buy_amazon'  => 'Buy on Amazon',
                'buy_mercado' => 'View on Mercado Livre',
                'buy_generic' => 'View offer',
            ),
            'es_ES' => array(
                'favorites'   => 'Nuestras recomendaciones',
                'pros'        => 'Pros',
                'cons'        => 'Contras',
                'buy_amazon'  => 'Comprar en Amazon',
                'buy_mercado' => 'Ver en Mercado Libre',
                'buy_generic' => 'Ver oferta',
            ),
            'pt_BR' => array(
                'favorites'   => 'Nossos Favoritos',
                'pros'        => 'Prós',
                'cons'        => 'Contras',
                'buy_amazon'  => 'Comprar na Amazon',
                'buy_mercado' => 'Ver no Mercado Livre',
                'buy_generic' => 'Ver oferta',
            ),
        );
        $set = $labels[ $locale ] ?? $labels['en_US'];
        return $set[ $key ] ?? ( $labels['en_US'][ $key ] ?? '' );
    }

    /**
     * Convert a leading / paragraph-separated Markdown blockquote TLDR (`> ##`) into
     * the wp:html + div format using site theme colours from production/site_key.
     */
    private function normalize_tldr_blockquote_to_wp_html(string $content, int $production_id): string
    {
        $replaced = false;
        $colors   = $this->resolve_tldr_accent_soft($production_id);

        return preg_replace_callback(
            '/(?:\A|\n\n)((?:>[ \t]*[^\n]*(?:\r?\n|$))+)/u',
            function (array $m) use (&$replaced, $colors): string {
                if ($replaced || ! $this->looks_like_tldr_blockquote($m[1])) {
                    return $m[0];
                }
                $replaced = true;

                $title = '';
                $body  = [];
                $lines = preg_split('/\r?\n/', trim($m[1]));
                foreach ($lines as $line) {
                    if (! preg_match('/^[ \t]*>(.*)/u', $line, $lm)) {
                        continue;
                    }
                    $inner = trim($lm[1]);
                    if ($inner === '') {
                        continue;
                    }
                    if (preg_match('/^##\s+(.+)$/u', $inner, $hm)) {
                        $title = trim($hm[1]);
                        continue;
                    }
                    $body[] = $inner;
                }

                if ($title === '' || $body === []) {
                    return $m[0];
                }

                $body_text = implode(' ', $body);
                $accent    = esc_attr($colors['accent']);
                $soft      = esc_attr($colors['soft']);
                $title_e   = esc_html($title);
                $body_e    = esc_html($body_text);

                $inner_html = '<strong>' . $title_e . '</strong>'
                    . '<p style="margin:0.5rem 0 0 0">' . $body_e . '</p>';

                $block_html = '<!-- wp:html -->'
                    . "\n"
                    . '<div style="background:' . $soft . '; border-left:4px solid ' . $accent . '; padding:1.2rem 1.5rem; border-radius:0 8px 8px 0; margin:1.5rem 0">'
                    . $inner_html
                    . '</div>'
                    . "\n"
                    . '<!-- /wp:html -->';

                return (str_starts_with($m[0], "\n\n") ? "\n\n" : '') . $block_html;
            },
            $content
        ) ?? $content;
    }

    /**
     * True when the preamble already contains a Parsedown-safe TLDR wp:html box.
     */
    private function preamble_has_valid_wp_html_tldr(string $preamble): bool
    {
        return (bool) preg_match(
            '/<!--\s*wp:html\s*-->[\s\S]*?\bborder-left\b[\s\S]*?<\s*ul\b[\s\S]*?<!--\s*\/wp:html\s*-->/iu',
            $preamble
        );
    }

    /**
     * Split body at the first real H2 section heading (## …), excluding metadata.
     *
     * @return array{0:string,1:string}|null [preamble, from_first_h2_inclusive]
     */
    private function split_content_at_first_h2(string $content): ?array
    {
        if (! preg_match('/\n(##\s+[^\n]+)/u', $content, $m, PREG_OFFSET_CAPTURE)) {
            return null;
        }

        $pos = $m[0][1];

        return [
            substr($content, 0, $pos),
            substr($content, $pos),
        ];
    }

    /**
     * Heuristic: one-line TLDR title before a Markdown bullet list (Writer drift).
     */
    private function looks_like_tldr_title_line(string $line): bool
    {
        $t = trim($line);
        $len = mb_strlen($t, 'UTF-8');
        if ($len < 12 || $len > 280) {
            return false;
        }
        if (preg_match('/^[#>\*\-\`\|]/u', $t)) {
            return false;
        }
        if (preg_match('/https?:\/\//iu', $t)) {
            return false;
        }
        if (preg_match('/:$/u', $t)) {
            return true;
        }

        return (bool) preg_match(
            '/essencial|esencial|resumo|resumen|guia\s+rápido|guía\s+rápida|quick\s+guide|key\s+takeaways|tl\s*dr|minuto|highlights|cheat\s*sheet|must\s*read|lo\s+esencial|guia\s+esencial|guía\s+esencial/i',
            $t
        );
    }

    /**
     * Convert inline **bold** segments to &lt;strong&gt;; escape the rest.
     */
    private function tldr_inline_markdown_bold_to_html(string $text): string
    {
        $out       = '';
        $remaining = $text;
        while ($remaining !== '') {
            $pos = strpos($remaining, '**');
            if ($pos === false) {
                $out .= esc_html($remaining);
                break;
            }
            if ($pos > 0) {
                $out .= esc_html(substr($remaining, 0, $pos));
            }
            $remaining = substr($remaining, $pos + 2);
            $end       = strpos($remaining, '**');
            if ($end === false) {
                $out .= esc_html('**' . $remaining);
                break;
            }
            $out       .= '<strong>' . esc_html(substr($remaining, 0, $end)) . '</strong>';
            $remaining = substr($remaining, $end + 2);
        }

        return $out;
    }

    /**
     * @param  list<string> $item_lines bullet text without leading marker
     * @return string|null full <!-- wp:html -->…<!-- /wp:html --> or null if invalid
     */
    private function build_tldr_wp_html_ul_block(string $title, array $item_lines, array $colors): ?string
    {
        if ($title === '' || count($item_lines) < 2) {
            return null;
        }

        $accent  = esc_attr($colors['accent']);
        $soft    = esc_attr($colors['soft']);
        $title_e = esc_html($title);

        $lis = '';
        foreach ($item_lines as $raw) {
            $raw = trim((string) $raw);
            if ($raw === '') {
                continue;
            }
            $lis .= '<li>' . $this->tldr_inline_markdown_bold_to_html($raw) . '</li>';
        }

        if (substr_count($lis, '<li>') < 2) {
            return null;
        }

        $inner_html = '<strong>' . $title_e . '</strong>'
            . '<ul style="margin:0.5rem 0 0 0; padding-left:1.2rem">'
            . $lis
            . '</ul>';

        return '<!-- wp:html -->'
            . "\n"
            . '<div style="background:' . $soft . '; border-left:4px solid ' . $accent . '; padding:1.2rem 1.5rem; border-radius:0 8px 8px 0; margin:1.5rem 0">'
            . $inner_html
            . '</div>'
            . "\n"
            . '<!-- /wp:html -->';
    }

    /**
     * Parse a trimmed block (title line + Markdown list lines) into title + item bodies.
     *
     * @return array{title:string,items:list<string>}|null
     */
    private function parse_tldr_markdown_title_and_bullet_lines(string $block): ?array
    {
        $raw_lines = preg_split('/\r?\n/', trim($block));
        if ($raw_lines === false) {
            return null;
        }

        $lines = [];
        foreach ($raw_lines as $line) {
            $line = trim($line);
            if ($line !== '') {
                $lines[] = $line;
            }
        }

        if (count($lines) < 3) {
            return null;
        }

        $title = '';
        $items = [];
        foreach ($lines as $line) {
            if (preg_match('/^[ \t]*[\*\-]\s+(.+)$/u', $line, $im)) {
                if ($title === '') {
                    return null;
                }
                $items[] = $im[1];
                continue;
            }
            if ($title !== '') {
                return null;
            }
            $title = $line;
        }

        if ($title === '' || count($items) < 2) {
            return null;
        }
        if (! $this->looks_like_tldr_title_line($title)) {
            return null;
        }

        return ['title' => $title, 'items' => $items];
    }

    /**
     * When the Writer emits a Markdown TLDR (title + * / - list, sometimes fenced in ```)
     * instead of wp:html, rebuild the TLDR box before References / Parsedown.
     */
    private function normalize_tldr_markdown_list_to_wp_html(string $content, int $production_id): string
    {
        $split = $this->split_content_at_first_h2($content);
        if ($split === null) {
            return $content;
        }

        [$preamble, $rest] = $split;
        if ($this->preamble_has_valid_wp_html_tldr($preamble)) {
            return $content;
        }

        $preRt = rtrim($preamble);
        if ($preRt === '') {
            return $content;
        }

        $colors = $this->resolve_tldr_accent_soft($production_id);

        $matched = false;
        $start    = 0;
        $len      = 0;
        $wp       = '';

        // 1) Fenced code block at end of preamble (``` … ```) containing title + bullets only.
        if (preg_match('/(?:^|\n)```[a-zA-Z0-9]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/um', $preRt, $fm, PREG_OFFSET_CAPTURE)) {
            $inner = trim($fm[1][0]);
            $parsed = $this->parse_tldr_markdown_title_and_bullet_lines($inner);
            if ($parsed !== null) {
                $wp = $this->build_tldr_wp_html_ul_block($parsed['title'], $parsed['items'], $colors);
                if ($wp !== null) {
                    $matched = true;
                    $start   = $fm[0][1];
                    $len     = strlen($fm[0][0]);
                }
            }
        }

        // 2) Unfenced: title paragraph + blank line(s) + Markdown list at end of preamble.
        if (! $matched) {
            $patterns = [
                '/\n\n([^\n]{12,280})\n\n((?:[ \t]*[\*\-]\s+[^\n]+\n?){2,})[ \t]*$/um',
                '/\n\n([^\n]{12,280})\n((?:[ \t]*[\*\-]\s+[^\n]+\n?){2,})[ \t]*$/um',
            ];
            foreach ($patterns as $re) {
                if (preg_match($re, $preRt, $um, PREG_OFFSET_CAPTURE)) {
                    $titleLine = trim($um[1][0]);
                    $listBlock = trim($um[2][0]);
                    if (! $this->looks_like_tldr_title_line($titleLine)) {
                        continue;
                    }
                    $items = [];
                    foreach (preg_split('/\r?\n/', $listBlock) as $ln) {
                        if (preg_match('/^[ \t]*[\*\-]\s+(.+)$/u', $ln, $im)) {
                            $items[] = $im[1];
                        }
                    }
                    $wp = $this->build_tldr_wp_html_ul_block($titleLine, $items, $colors);
                    if ($wp !== null) {
                        $matched = true;
                        $start   = $um[0][1];
                        $len     = strlen($um[0][0]);
                        break;
                    }
                }
            }
        }

        if (! $matched || $wp === '') {
            return $content;
        }

        $newPreRt = substr($preRt, 0, $start) . "\n\n" . $wp . "\n\n" . substr($preRt, $start + $len);
        $newPreamble = $newPreRt . substr($preamble, strlen($preRt));

        return $newPreamble . $rest;
    }

    // ── Markdown → Gutenberg ──────────────────────────────────────────────────

    private function markdown_to_gutenberg(string $markdown): string
    {
        return Costudio_Publisher_Markdown_Renderer::to_gutenberg($markdown, $this->accent_color);
    }

    private function looks_like_reviewer_json_output(string $output): bool
    {
        $text = trim($output);
        if ($text === '') {
            return false;
        }

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

        $head = mb_substr($text, 0, 2000);

        if (
            preg_match('/^\s*\{/u', $text)
            && preg_match('/"reasoning_trace"\s*:/u', $head)
            && preg_match('/"dimensions"\s*:/u', $head)
        ) {
            return true;
        }

        return preg_match('/"approved"\s*:\s*(true|false)/u', $head) === 1
            && preg_match('/"diagnostic_score"\s*:/u', $head) === 1
            && preg_match('/"fixes"\s*:/u', $head) === 1;
    }

    // ── Category helper ───────────────────────────────────────────────────────

    private function get_or_create_category(string $name): ?int
    {
        if (empty($name)) return null;

        $slug = sanitize_title($name);

        $search = wp_safe_remote_get(
            "{$this->site_url}/wp-json/wp/v2/categories?" . http_build_query([
                'search'   => $name,
                'per_page' => 20,
            ]),
            costudio_safe_remote_args(['timeout' => 15, 'headers' => ['Authorization' => $this->auth]])
        );

        if (! is_wp_error($search)) {
            $cats = json_decode(wp_remote_retrieve_body($search), true);
            foreach ((array) $cats as $cat) {
                if (
                    mb_strtolower($cat['name']) === mb_strtolower($name) ||
                    $cat['slug'] === $slug
                ) {
                    return (int) $cat['id'];
                }
            }
        }

        $create = wp_safe_remote_post("{$this->site_url}/wp-json/wp/v2/categories", costudio_safe_remote_args([
            'timeout' => 15,
            'headers' => [
                'Authorization' => $this->auth,
                'Content-Type'  => 'application/json',
            ],
            'body' => wp_json_encode(['name' => $name, 'slug' => $slug]),
        ]));

        if (! is_wp_error($create)) {
            $cat = json_decode(wp_remote_retrieve_body($create), true);
            return isset($cat['id']) ? (int) $cat['id'] : null;
        }

        return null;
    }

    // ── Text extraction helpers ───────────────────────────────────────────────

    /**
     * Marker list for extracting the A02 editorial title (H1) from the brief.
     * New labels (current agent-02 prompt, "Article Title (H1)" family) are
     * tried first; historic labels ("Optimized Title" / "Título Otimizado" /
     * "Título Optimizado") are kept as compat fallbacks for at least one
     * version per the Item 2 migration contract. Shared by publish() and
     * preview() so the two extraction points cannot drift out of sync again.
     */
    private static function title_extraction_markers(): array
    {
        return [
            '1.1 Article Title (H1)', 'Article Title (H1)',
            '1.1 Título do Artigo (H1)', 'Título do Artigo (H1)',
            '1.1 Título del Artículo (H1)', 'Título del Artículo (H1)',
            '1.1 Optimized Title', 'Optimized Title',
            '1.1 Título Otimizado', 'Título Otimizado',
            '1.1 Título Optimizado', 'Título Optimizado',
            '### 1.1',
        ];
    }

    /**
     * Single resolver for the editorial title (H1), shared by publish() and
     * preview() so the two extraction points cannot diverge again. Precedence:
     * 1. Legacy shielded `[SEO_TITLE]` tag in the A02 brief (never emitted by
     *    the current prompt, kept for historic productions that used it).
     * 2. A02's labeled title line — new "Article Title (H1)" family first,
     *    historic "Optimized Title" family as compat fallback.
     * 3. Last resort: Agent 04's echoed "Title:"/"Título:" line.
     */
    private static function extract_editorial_title(string $brief, string $article): string
    {
        $title = Costudio_Publisher_Text_Extractor::tag($brief, 'SEO_TITLE')
            ?: Costudio_Publisher_Text_Extractor::line($brief, self::title_extraction_markers());
        if (empty($title)) {
            $title = Costudio_Publisher_Text_Extractor::labeled($article, ['Título', 'Title']);
        }
        return $title;
    }

    private function extract_line(string $content, array $markers): string
    {
        return Costudio_Publisher_Text_Extractor::line($content, $markers);
    }

    /**
     * Unicode-safe character count. `strlen()` counts bytes, which over-counts
     * every accented/multi-byte character (PT/ES titles, emoji) — not a valid
     * authority for the SEO title/meta description length QA (Item 2, fatia
     * 2/3). Prefers `mb_strlen()`; falls back to a PCRE codepoint count when
     * mbstring is not loaded (confirmed absent in this project's standalone
     * test CLI — see qa-gate-regression-test.php's `iu`-flag precedent).
     */
    private function unicode_strlen(string $str): int
    {
        if (function_exists('mb_strlen')) {
            return mb_strlen($str, 'UTF-8');
        }
        preg_match_all('/./us', $str, $m);
        return count($m[0]);
    }

    /**
     * Non-blocking QA warnings for SEO title / meta description (Item 2,
     * fatia 2/3) — deliberately separate from scan_publish_quality()'s
     * blocker list per the plan's contract: length/fallback issues here are
     * never a reason to fail publication, only to log visibility.
     *
     * @param string $seo_title        Final value after the H1 fallback (if any).
     * @param string $seo_title_source 'agent_06' or 'fallback_h1'.
     * @param string $seo_desc         Final value after the brief fallback (if any).
     * @param string $seo_desc_source  'agent_06' or 'fallback_brief'.
     * @return array<int,string>
     */
    private function collect_seo_metadata_warnings(
        string $seo_title,
        string $seo_title_source,
        string $seo_desc,
        string $seo_desc_source
    ): array {
        $warnings = [];

        if ('fallback_h1' === $seo_title_source) {
            $warnings[] = sprintf(
                'SEO title absent from Agent 06 — falling back to the H1 (%d chars)',
                $this->unicode_strlen($seo_title)
            );
        }

        $seo_title_len = $this->unicode_strlen($seo_title);
        if ($seo_title_len > 65) {
            $warnings[] = sprintf('SEO title is %d characters, above the 65-character target', $seo_title_len);
        }

        if (empty($seo_desc)) {
            $warnings[] = 'Meta description is empty';
        } else {
            $seo_desc_len = $this->unicode_strlen($seo_desc);
            if ($seo_desc_len < 150 || $seo_desc_len > 160) {
                $warnings[] = sprintf('Meta description is %d characters, outside the 150-160 target range', $seo_desc_len);
            }
        }

        if ('fallback_brief' === $seo_desc_source && ! empty($seo_desc)) {
            $warnings[] = 'Meta description absent from Agent 06 — falling back to the brief';
        }

        return $warnings;
    }

    /**
     * @param string       $content
     * @param string|array $label
     */
    private function extract_labeled(string $content, $label): string
    {
        return Costudio_Publisher_Text_Extractor::labeled($content, $label);
    }

    private function extract_json_ld(string $content): string
    {
        return Costudio_Publisher_Schema::extract_json_ld($content);
    }

    private function extract_tag(string $content, string $tag): string
    {
        return Costudio_Publisher_Text_Extractor::tag($content, $tag);
    }

    private function strip_division_markers(string $content): string
    {
        return Costudio_Publisher_Content_Normalizer::strip_division_markers($content);
    }

    /**
     * Defensive guardrail (Item 3, subitem 3A): the Writer is instructed to
     * NEVER write a raw `<iframe>` — the `[VIDEO:...]` marker is the only
     * accepted way to place a video. Strip any raw iframe the model wrote
     * anyway (ignoring the instruction, or a prompt-injection attempt) before
     * the content is ever converted to Gutenberg blocks.
     */
    private function strip_raw_iframes(string $content): string
    {
        if (false === stripos($content, '<iframe')) {
            return $content;
        }
        $stripped = preg_replace('/<iframe\b[^>]*>.*?<\/iframe>/is', '', $content);
        // A malformed/unclosed <iframe> (no matching </iframe>) would survive
        // the pair-matching regex above — catch the bare opening tag too.
        $stripped = preg_replace('/<iframe\b[^>]*\/?>/is', '', (string) $stripped);
        return $stripped ?? $content;
    }

    /**
     * Resolve `[VIDEO:videoId]` markers into a real Gutenberg embed block —
     * or strip them, when the ID doesn't match the production's validated
     * candidate (structurally impossible for the model to "swap the ID" and
     * have it stick) or when a video was already embedded once (max one per
     * article). Runs BEFORE markdown_to_gutenberg(), same point in the
     * pipeline as [IMG:...] marker resolution.
     *
     * A marker whose ID shape isn't even well-formed (not exactly 11
     * `[A-Za-z0-9_-]` chars) is deliberately NOT matched here — it survives
     * as literal text, which the QA gate's `[VIDEO:` blocker then catches as
     * a residual/unresolved marker. That is the intended safety net for a
     * genuinely malformed marker, as opposed to a well-formed-but-wrong ID
     * (handled here, non-blocking — the article just publishes without it).
     */
    private function resolve_video_markers(string $content, array $inputs, int $production_id): string
    {
        if (false === strpos($content, '[VIDEO:')) {
            return $content;
        }

        $candidate = (array) ($inputs['video_candidate'] ?? []);
        $valid_id  = ! empty($candidate['ok']) ? (string) ($candidate['video_id'] ?? '') : '';
        $embed_used = false;

        $result = preg_replace_callback(
            '/\[VIDEO:\s*([A-Za-z0-9_-]{11})\s*\]/',
            function (array $m) use ($valid_id, &$embed_used, $production_id) {
                $marker_id = $m[1];

                if ($embed_used) {
                    $this->log_warning(
                        'publisher.publish.video_marker_extra_dropped',
                        ['production_id' => $production_id, 'video_id' => $marker_id],
                        $production_id
                    );
                    return '';
                }

                if ('' === $valid_id || $marker_id !== $valid_id) {
                    $this->log_warning(
                        'publisher.publish.video_marker_rejected',
                        ['production_id' => $production_id, 'marker_id' => $marker_id, 'expected_id' => $valid_id],
                        $production_id
                    );
                    return '';
                }

                $embed_used = true;
                return $this->build_video_embed_block($marker_id);
            },
            $content
        );

        return $result ?? $content;
    }

    /**
     * The standard WordPress core `wp:embed` block shape for a YouTube video
     * — matches what WP itself generates when a user pastes a bare YouTube
     * URL on its own line, so it renders identically to a native embed.
     */
    private function build_video_embed_block(string $video_id): string
    {
        $url = 'https://www.youtube.com/watch?v=' . $video_id;
        $attrs = wp_json_encode([
            'url'              => $url,
            'type'             => 'video',
            'providerNameSlug' => 'youtube',
            'responsive'       => true,
            'className'        => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
        ]);

        return "\n\n<!-- wp:embed {$attrs} -->\n"
            . '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio">'
            . "<div class=\"wp-block-embed__wrapper\">\n{$url}\n</div></figure>\n"
            . "<!-- /wp:embed -->\n\n";
    }

    /**
     * Remove a duplicate article title that appears as the first markdown
     * heading. The post_title is stored separately by WordPress, so an H1
     * (or even an H2 when the LLM "promotes" the title) at the top of the
     * markdown body produces a visible duplicate just below the post header.
     *
     * Behaviour:
     *   - Always strips a leading H1 (`# ...`).
     *   - Strips the FIRST `## ...` only when it sits at the very top of the
     *     content body (after extract_seo() ran and removed `Título:`/`Meta
     *     Description:`/`Slug:` lines). This prevents accidentally removing
     *     legitimate first-section H2s when the Writer omitted the duplicate.
     *
     * @param string $content
     */
    private function strip_leading_h1(string $content): string
    {
        return Costudio_Publisher_Content_Normalizer::strip_leading_h1($content);
    }

    private function convert_callouts(string $markdown): string
    {
        return Costudio_Publisher_Content_Normalizer::convert_callouts($markdown);
    }

    private function sanitize_placeholder_urls(string $content): string
    {
        return Costudio_Publisher_Content_Normalizer::sanitize_placeholder_urls($content);
    }

    /**
     * Auto-correct the two safe, deterministic content defects before publish:
     * empty headings are removed and a body <h1> (WordPress already renders the
     * title as the page's only H1) is demoted to <h2>. Returns the fixed content
     * plus human-readable corrections (logged as warnings by the caller).
     * Pure/stateless — unit-testable without WordPress.
     *
     * @return array{content:string,corrections:array<int,string>}
     */
    private function autocorrect_content_defects(string $content): array
    {
        $corrections = [];

        // Remove VISUALLY empty headings first (optionally wrapped in wp:heading
        // comments) so an empty <h1> is dropped rather than counted as a demotion.
        // "Visually empty" = nothing left after stripping inner tags (<br>, <span>…),
        // decoding entities, and treating &nbsp;/zero-width chars as blank — so
        // "<h2>&nbsp;</h2>", "<h3><br></h3>" and "<h4><span> </span></h4>" all go.
        $empty_count = 0;
        $content     = preg_replace_callback(
            '/(?:<!--\s*wp:heading[^>]*-->\s*)?<h([1-6])\b[^>]*>(.*?)<\/h\1>(?:\s*<!--\s*\/wp:heading\s*-->)?/is',
            static function (array $m) use (&$empty_count): string {
                $visual = preg_replace(
                    '/[\s\x{00A0}\x{200B}\x{FEFF}]+/u',
                    '',
                    (string) html_entity_decode(strip_tags($m[2]), ENT_QUOTES | ENT_HTML5)
                );
                if ('' === (string) $visual) {
                    $empty_count++;
                    return '';
                }
                return $m[0];
            },
            $content
        ) ?? $content;
        if ($empty_count > 0) {
            $corrections[] = sprintf('removed %d empty heading(s)', $empty_count);
        }

        // Demote any surviving body <h1> to <h2> — fix both the tag and the
        // Gutenberg block's "level":1 attribute.
        $content = preg_replace('/<h1(\b[^>]*)>(.*?)<\/h1>/is', '<h2$1>$2</h2>', $content, -1, $tag_count) ?? $content;
        $content = preg_replace('/(<!--\s*wp:heading\s+\{[^}]*"level"\s*:\s*)1(\b)/i', '${1}2$2', $content) ?? $content;
        if ($tag_count > 0) {
            $corrections[] = sprintf('demoted %d body <h1> to <h2>', $tag_count);
        }

        return ['content' => $content, 'corrections' => $corrections];
    }

    /**
     * Deterministic pre-publish QA scan of the FINAL rendered content + title.
     * Returns a list of BLOCKER strings (empty = clean); the caller aborts the
     * publish when non-empty. Pure/stateless so it is unit-testable and so the
     * policy lives at the call site. Must run AFTER the content is fully rendered
     * (references resolved, image markers replaced, Markdown -> Gutenberg) so a
     * token legitimately resolved earlier is never flagged.
     *
     * @param bool $require_title false for the update flow, which preserves the
     *                            existing live title (no title travels in the PUT).
     * @return array<int,string>
     */
    private function scan_publish_quality(string $content, string $title, bool $require_title = true): array
    {
        $blockers = [];

        // 1. Empty title — a titleless article must never publish.
        if ($require_title && '' === trim(strip_tags($title))) {
            $blockers[] = 'empty title';
        }

        // 2. Unresolved template tokens. The body is scanned with code regions
        //    (<pre>/<code>/<kbd>/<samp>) removed, so a tutorial that legitimately
        //    shows template syntax is not blocked; the title is scanned as-is.
        //    Specific tokens only — never a blanket "[...]".
        $haystack       = $title . "\n" . $this->strip_code_regions($content);
        $token_patterns = [
            // Multi-part template tokens ([SITE_NAME], [NICHE_COLOR_SOFT],
            // [AUTHOR_NAME], [PUBLISHER_LOGO_URL], …) — shape-based (UPPER with
            // underscores) so new pipeline tokens are caught without updating
            // this list. Underscore-only: a "/" is legitimate editorial shorthand
            // (e.g. "[SEO/AEO]", "[B2B/B2C]") and must not be treated as a token
            // separator, so it is excluded here and handled as a literal below.
            '/\[[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\]/',
            // The one real pipeline token that contains a slash — matched literally.
            '/\[MONTH\/YEAR\]/',
            // Single-word template tokens that have no separator.
            '/\[(?:KEYWORD|NICHE|YEAR|ANGLE|LANGUAGE|CONTEXT|PLATFORMS|LENGTH)\]/',
            // Update-protocol markers: [SECTION 0], [SECTION N], [SECTION 1: Title],
            // [INSERT AFTER SECTION N], [INSERT BEFORE SECTION 0] (must be followed
            // by a number or literal N, so "[Section headings matter]" is not flagged).
            '/\[(?:SECTION|INSERT (?:AFTER|BEFORE) SECTION)\s+(?:\d+|N)\b[^\]]*\]/i',
            // Schema / URL placeholders.
            '/\b(?:PAGE_URL|URL_DA_PAGINA|URL_DE_LA_PAGINA|URL_DE_LA_PORTADA|URL_DA_CAPA|URL_CAPA|URL_PORTADA|COVER_URL)\b/',
            // Unresolved citation placeholder + leftover image/video markers.
            // A [VIDEO:...] marker only ever reaches here if resolve_video_markers()
            // could not resolve it — either a malformed ID (safety net, see that
            // method's docblock) or the resolver never ran (a real bug). Either
            // way, a residual marker must block, never publish as literal text.
            '/\{\{\s*ref\s*:/i',
            '/\[IMG:/i',
            '/\[VIDEO:/i',
            '/\[\/?IMAGE_LIST\]/i',
        ];
        foreach ($token_patterns as $re) {
            if (preg_match($re, $haystack, $m)) {
                $blockers[] = 'unresolved template token: ' . trim($m[0]);
            }
        }

        // 3. Internal/editorial text leaked into a HEADING (EN/PT/ES). Headings only
        //    (never body prose), matched conservatively so an article that
        //    legitimately says "How to Draft a Will" is never blocked.
        foreach ($this->extract_heading_texts($content) as $heading) {
            $raw = trim($heading['text']);
            if ('' !== $raw && $this->is_internal_heading($raw)) {
                $blockers[] = 'internal/editorial text in a heading: "' . $raw . '"';
            }
        }

        return array_values(array_unique($blockers));
    }

    /**
     * True when a heading's text is an internal/editorial leak rather than real
     * article structure. Conservative and multilingual (EN/PT/ES): matches bracket
     * tokens, a "… — draft/rascunho/borrador" status suffix, exact internal labels,
     * or a leading "TODO:/Nota interna:" label — never a common word by substring,
     * so "How to Draft a Will" or "Todos os passos" are never flagged.
     */
    private function is_internal_heading(string $raw): bool
    {
        $raw = trim($raw);
        if ('' === $raw) {
            return false;
        }
        // Case-insensitive + Unicode (`iu`) applied to the ORIGINAL text — no
        // lowercasing, so it never depends on mbstring and never corrupts accented
        // UTF-8 (PT/ES) via a locale-sensitive strtolower.
        //
        // Bracket-token heading, e.g. "[SECTION 1]".
        if (preg_match('/^\[[^\]]+\]$/', $raw)) {
            return true;
        }
        // Status suffix: "… — draft / rascunho / borrador / wip / todo".
        if (preg_match('/[\-\x{2013}\x{2014}(]\s*(?:draft|wip|placeholder|todo|tbd|rascunho|borrador)\s*\)?\s*$/iu', $raw)) {
            return true;
        }
        // Whole-heading internal label (EN/PT/ES). Accent variants are written as
        // character classes so it matches with or without diacritics and does not
        // depend on Unicode case folding.
        if (preg_match('/^(?:draft|wip|todo|tbd|placeholder|lorem ipsum|rascunho|borrador|onboarding update|required actions|a[cç][oõ]es necess[aá]rias|acciones necesarias|reviewer feedback|feedback do revisor(?: editorial)?|feedback del revisor(?: editorial)?|editorial reviewer|revisor editorial|nota interna|internal note|note to self|section 0|se[cç][aã]o 0|secci[oó]n 0)$/iu', $raw)) {
            return true;
        }
        // Leading internal label: "TODO: …", "Nota interna: …", "Draft — …".
        if (preg_match('/^(?:draft|wip|todo|tbd|placeholder|rascunho|borrador|note to self|internal|nota interna|internal note)\s*[:\-\x{2013}\x{2014}]/iu', $raw)) {
            return true;
        }
        return false;
    }

    /**
     * Blank out the contents of code regions (<pre>/<code>/<kbd>/<samp>) and
     * <script> blocks (JSON-LD schema, or anything else the article emitted
     * literally) so the token/price scans never flag template syntax or
     * structured data an article legitimately displays.
     */
    private function strip_code_regions(string $content): string
    {
        return preg_replace('/<(pre|code|kbd|samp|script)\b[^>]*>.*?<\/\1>/is', ' ', $content) ?? $content;
    }

    /**
     * Experimental, non-blocking heuristic (Item 2, subitem 2B): flag a
     * commercial/price figure that has no source link nearby. This is a
     * proximity check, NOT a claim↔source validator — it never asserts that a
     * nearby link actually supports the figure, only that publishing an
     * unsourced price/plan claim deserves a human's attention. Disableable
     * via the `costudio_price_warning_enabled` filter if the real corpus
     * shows too many false positives.
     *
     * High-precision only: currency symbols/codes and recurring-price
     * qualifiers attached to a number. Deliberately does NOT match bare
     * percentages or generic numbers — those are common and not a "price"
     * claim, and treating them as one would be pure noise.
     *
     * @return array<int,string>
     */
    private function scan_commercial_price_warnings(string $content): array
    {
        if (! apply_filters('costudio_price_warning_enabled', true)) {
            return [];
        }

        // Code/script regions excluded first (Item 3's exclusion, which now
        // also covers <script> — a literal JSON-LD example is not a prose
        // claim), then the auto-assembled References/Bibliography section.
        // This scanner runs AFTER markdown_to_gutenberg() — the article is
        // already rendered HTML at this point — so the heading must be
        // matched in its <h1-6> form; a Markdown-only "## References" match
        // would silently never fire on the real content shape.
        $haystack = $this->strip_code_regions($content);
        foreach ([
            // Rendered HTML/Gutenberg form — the real shape at this point.
            '/<h[1-6]\b[^>]*>\s*(?:References|Referências|Referencias|Bibliography|Bibliografía|Bibliografia)\s*<\/h[1-6]>.*\z/isu',
            // Markdown form, kept defensively in case this method is ever
            // invoked earlier in the pipeline (pre markdown_to_gutenberg()).
            '/(?:\R|^)\s*#{1,6}\s+(?:References|Referências|Referencias|Bibliography|Bibliografía|Bibliografia)\b.*\z/isu',
        ] as $references_pattern) {
            $haystack = preg_replace($references_pattern, '', $haystack) ?? $haystack;
        }

        $currency_patterns = [
            // Symbol immediately before a number: R$99, US$49, $19.99, €29, £15
            '/(?:R\$|US\$|\$|€|£)\s?\d[\d.,]*/u',
            // ISO code immediately after a number: 49 USD, 99,90 BRL, 29 EUR, 15 GBP
            '/\d[\d.,]*\s?(?:USD|BRL|EUR|GBP)\b/u',
            // Recurring-price qualifier attached to a number: $49/month, 99/mês
            '/\d[\d.,]*\s?\/\s?(?:mês|mes|month|mo|ano|año|year|yr)\b/iu',
        ];

        // Gather every raw match from every pattern first. A single mention
        // like "$99/month" matches BOTH the symbol pattern ("$99") and the
        // recurring-qualifier pattern ("99/month") — merging overlapping
        // spans below turns that into exactly one warning, not two.
        $raw_matches = [];
        foreach ($currency_patterns as $pattern) {
            if (! preg_match_all($pattern, $haystack, $matches, PREG_OFFSET_CAPTURE)) {
                continue;
            }
            foreach ($matches[0] as [$match_text, $offset]) {
                $raw_matches[] = ['start' => $offset, 'end' => $offset + strlen($match_text)];
            }
        }
        if (empty($raw_matches)) {
            return [];
        }
        usort($raw_matches, static function (array $a, array $b): int {
            return $a['start'] <=> $b['start'];
        });

        $merged = [];
        foreach ($raw_matches as $m) {
            $last = count($merged) - 1;
            if ($last >= 0 && $m['start'] <= $merged[$last]['end']) {
                $merged[$last]['end'] = max($merged[$last]['end'], $m['end']);
            } else {
                $merged[] = $m;
            }
        }

        // Anchor spans (HTML <a>...</a> and Markdown [text](url)), computed
        // once. `.*?` matches the FULL tag regardless of href length, so a
        // price wrapped in a link with a very long URL is still recognized —
        // unlike searching a fixed-radius text window for the literal "<a".
        $anchor_spans = [];
        if (preg_match_all('/<a\b[^>]*>.*?<\/a>|\[[^\]]*\]\([^)]*\)/isu', $haystack, $anchor_matches, PREG_OFFSET_CAPTURE)) {
            foreach ($anchor_matches[0] as [$anchor_text, $anchor_offset]) {
                $anchor_spans[] = ['start' => $anchor_offset, 'end' => $anchor_offset + strlen($anchor_text)];
            }
        }

        $proximity = 80; // chars of slack on either side of an anchor span.
        $warnings  = [];
        foreach ($merged as $span) {
            $has_nearby_link = false;
            foreach ($anchor_spans as $anchor) {
                if ($span['start'] < $anchor['end'] + $proximity && $span['end'] > $anchor['start'] - $proximity) {
                    $has_nearby_link = true;
                    break;
                }
            }
            if (! $has_nearby_link) {
                $match_text = trim(substr($haystack, $span['start'], $span['end'] - $span['start']));
                $warnings[] = sprintf('commercial/price claim without a nearby link: "%s"', $match_text);
            }
        }

        return $warnings;
    }

    /**
     * Extract heading blocks (level + plain text) from rendered Gutenberg/HTML
     * content, in document order.
     *
     * @return array<int,array{level:int,text:string}>
     */
    private function extract_heading_texts(string $content): array
    {
        $out = [];
        if (preg_match_all('/<h([1-6])\b[^>]*>(.*?)<\/h\1>/is', $content, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $m) {
                $text  = trim(preg_replace('/\s+/u', ' ', strip_tags($m[2])) ?? '');
                $out[] = ['level' => (int) $m[1], 'text' => html_entity_decode($text, ENT_QUOTES)];
            }
        }
        return $out;
    }

    // ── Preview (no publish, no image generation) ────────────────────────────

    /**
     * Build a preview of the final article HTML without publishing or
     * generating images — as close as safely possible to what publish()
     * actually produces, without triggering any of its side effects.
     *
     * Deliberately does NOT construct a Costudio_WP_Publisher instance: the
     * constructor requires an active, connected site entry (including
     * LOCAL_SITE_KEY, which only exists once the "connect this site" wizard
     * step has run — see admin/ajax/class-costudio-admin-ajax-misc.php). An
     * install that only ever publishes remotely never has that entry, so the
     * previous version of this method (`new self(self::LOCAL_SITE_KEY)`)
     * threw on every preview call on exactly that kind of install. Every
     * helper this method needs is a pure static string transform, so it is
     * called directly against the same classes publish() delegates to,
     * bypassing the constructor's site-config requirement entirely.
     *
     * Image markers [IMG:...] resolve to the real generated image when
     * Costudio_Production::get_images_manifest() already has one (a pure DB
     * read — never triggers generation); otherwise to a placeholder figure.
     *
     * NOTE: this hand-mirrors the cleanup chain publish() runs (same order,
     * same static helpers) rather than sharing one method with it — publish()
     * is the plugin's most sensitive, heavily-tested path (idempotent ledger,
     * durable jobs) and isn't worth refactoring for this. If that chain
     * changes, this needs a matching update.
     *
     * @param  array $data  Keys: production_id, output_02, output_04, output_05, inputs
     * @return array { title, slug, meta_description, seo_title, focus_keyword, html }
     */
    public static function preview(array $data): array
    {
        $production_id = (int) ($data['production_id'] ?? 0);
        $inputs        = (array) ($data['inputs'] ?? []);
        $brief         = (string) ($data['output_02'] ?? '');
        $article       = (string) ($data['output_04'] ?? '');
        $design        = (string) ($data['output_05'] ?? '');
        $accent        = (string) ($inputs['site_color'] ?? '');

        // Reuse the exact resolver used by publish(); this pure static helper
        // does not require a connected-site constructor or trigger side effects.
        $title = self::extract_editorial_title($brief, $article);

        $slug = ltrim(Costudio_Publisher_Text_Extractor::line($brief, ['1.3 URL Slug', 'URL Slug', '### 1.3']), '/');
        $meta = Costudio_Publisher_Text_Extractor::line($brief, ['1.2 Meta Description', 'Meta Description', '### 1.2']);

        $seo_title = Costudio_Publisher_Text_Extractor::labeled($design, 'SEO Title') ?: $title;
        $seo_desc  = Costudio_Publisher_Text_Extractor::labeled($design, 'Meta Description') ?: $meta;
        $focus_kw  = Costudio_Publisher_Text_Extractor::labeled($design, 'Focus Keyword') ?: '';

        // Same cleanup chain publish() runs before markdown_to_gutenberg(),
        // called directly against the static helpers it delegates to.
        $content = preg_replace(
            '/^(Title|Título|Meta Description|Meta Descripción|Descrição Meta|Meta Descrição|Meta-descrição|Slug|URL Slug|URL):.*$/mu',
            '',
            $article
        );
        $content = trim((string) $content);
        $content = Costudio_Publisher_Content_Normalizer::convert_callouts($content);
        $content = Costudio_Publisher_Content_Normalizer::strip_division_markers($content);
        $content = Costudio_Publisher_Content_Normalizer::strip_leading_h1($content);
        $content = preg_replace('/^(#+)\s*H[2-6]:\s*/imu', '$1 ', (string) $content);
        $content = Costudio_Publisher_Content_Normalizer::sanitize_placeholder_urls((string) $content);
        $content = Costudio_Publisher_Content_Normalizer::strip_editorial_disclaimers($content);
        $content = Costudio_Publisher_Schema::strip_json_ld($content);
        $content = Costudio_Publication_Recovery::strip_markers($content);
        $content = self::resolve_video_markers_for_preview($content, $inputs);
        $content = self::resolve_image_markers_for_preview($content, $production_id);

        $html = Costudio_Publisher_Markdown_Renderer::to_gutenberg($content, $accent);

        return [
            'title'            => $title,
            'slug'             => $slug,
            'meta_description' => $meta,
            'seo_title'        => $seo_title,
            'focus_keyword'    => $focus_kw,
            'html'             => $html,
        ];
    }

    /**
     * Preview-only mirror of resolve_video_markers() — same marker syntax and
     * validation (only the one video the pipeline already vetted may embed),
     * without the instance-bound logging resolve_video_markers() does (not
     * meaningful for a read-only preview click) or the Gutenberg block-comment
     * wrapper (invisible either way once rendered as HTML).
     */
    private static function resolve_video_markers_for_preview(string $content, array $inputs): string
    {
        if (false === strpos($content, '[VIDEO:')) {
            return $content;
        }
        $candidate  = (array) ($inputs['video_candidate'] ?? []);
        $valid_id   = ! empty($candidate['ok']) ? (string) ($candidate['video_id'] ?? '') : '';
        $embed_used = false;

        $result = preg_replace_callback(
            '/\[VIDEO:\s*([A-Za-z0-9_-]{11})\s*\]/',
            function (array $m) use ($valid_id, &$embed_used) {
                $marker_id = $m[1];
                if ($embed_used || '' === $valid_id || $marker_id !== $valid_id) {
                    return '';
                }
                $embed_used = true;
                $url = 'https://www.youtube.com/watch?v=' . $marker_id;
                return "\n\n<figure class=\"wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio\">"
                    . '<div class="wp-block-embed__wrapper">' . esc_url($url) . '</div></figure>' . "\n\n";
            },
            $content
        );
        return $result ?? $content;
    }

    /**
     * Preview-only [IMG:...] marker resolution. Reads
     * Costudio_Production::get_images_manifest() — a pure DB read — and shows
     * the real generated image when one is already done; otherwise a styled
     * placeholder (filename + alt, both already present in the marker text,
     * so no manifest lookup is required for the placeholder path). NEVER
     * instantiates Costudio_Image_Generator: this method must not be able to
     * trigger generation, upload, or quota usage from a preview click.
     */
    private static function resolve_image_markers_for_preview(string $content, int $production_id): string
    {
        $items = [];
        if ($production_id > 0 && class_exists('Costudio_Production')) {
            $manifest = Costudio_Production::get_images_manifest($production_id);
            $items    = $manifest['items'] ?? [];
        }

        $result = preg_replace_callback(
            '/\[IMG:\s*([^\|\]]+)\s*\|\s*([^\]]+)\]/i',
            static function (array $m) use ($items) {
                $filename = trim($m[1]);
                $alt      = trim($m[2]);
                $item     = $items[$filename] ?? null;

                if (is_array($item) && 'done' === ($item['status'] ?? '') && ! empty($item['url'])) {
                    $url      = esc_url((string) $item['url']);
                    $safe_alt = esc_attr((string) ($item['alt'] ?? $alt));
                    $caption  = trim((string) ($item['caption'] ?? ''));
                    $figure   = "\n\n<figure class=\"wp-block-image\"><img src=\"{$url}\" alt=\"{$safe_alt}\" />";
                    if ('' !== $caption) {
                        $figure .= '<figcaption>' . esc_html($caption) . '</figcaption>';
                    }
                    return $figure . '</figure>' . "\n\n";
                }

                $safe_filename = esc_html($filename);
                $safe_alt      = esc_html($alt);
                return "\n\n<figure class=\"fos-preview-image-placeholder\">"
                    . '<div class="fos-preview-image-placeholder__box">'
                    . "<span class=\"fos-preview-image-placeholder__name\">\u{1F5BC} {$safe_filename}</span>"
                    . "<span class=\"fos-preview-image-placeholder__alt\">{$safe_alt}</span>"
                    . '</div></figure>' . "\n\n";
            },
            $content
        );
        return $result ?? $content;
    }
}
