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

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

        // 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    = $this->extract_tag($brief, 'SEO_TITLE') ?: $this->extract_line($brief, ['1.1 Título Otimizado', 'Título Otimizado', '### 1.1']);
        $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 ───────────
        if (empty($title)) {
            $title = $this->extract_labeled($article, ['Título', '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']), '/');
        }

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

        // ── 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);

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

        // ── 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);

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

        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 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 === '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();
        }

        // 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 ───────────────────────────────────────────────

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

    /**
     * @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);
    }

    /**
     * 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);
    }

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

    /**
     * Build a preview of the final article HTML without publishing or generating images.
     *
     * Image markers [IMG:...] are replaced with placeholder figures so the user
     * can see positioning.  Gutenberg block comments are stripped for a cleaner
     * reading experience.
     *
     * @param  array $data  Keys: output_02, output_04, output_05
     * @return array { title, slug, meta_description, seo_title, focus_keyword, html }
     */
    public static function preview(array $data): array
    {
        // Use a dummy instance to access private helpers.
        $self = new self(self::LOCAL_SITE_KEY);

        $brief   = $data['output_02'] ?? '';
        $article = $data['output_04'] ?? '';
        $design  = $data['output_05'] ?? '';

        // Extract SEO metadata.
        $title     = $self->extract_line($brief, ['1.1 Título Otimizado', 'Título Otimizado', '### 1.1']);
        $slug      = ltrim($self->extract_line($brief, ['1.3 URL Slug', 'URL Slug', '### 1.3']), '/');
        $meta      = $self->extract_line($brief, ['1.2 Meta Description', 'Meta Description', '### 1.2']);
        $seo_title = $self->extract_labeled($design, 'SEO Title')       ?: $title;
        $seo_desc  = $self->extract_labeled($design, 'Meta Description') ?: $meta;
        $focus_kw  = $self->extract_labeled($design, 'Focus Keyword')    ?: '';

        // Clean article — strip metadata header lines in all 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 = $self->convert_callouts($content);
        $content = $self->strip_division_markers($content);
        $content = $self->strip_leading_h1($content);
        $content = preg_replace('/^(#+)\s*H[2-6]:\s*/imu', '$1 ', $content);
        $content = $self->sanitize_placeholder_urls($content);

        // Replace image markers with placeholder figures.
        $content = preg_replace_callback(
            '/\[IMG:\s*([^\|\]]+\.webp)\s*\|\s*([^\]]+)\]/i',
            function (array $m) {
                $filename = esc_html($m[1]);
                $alt      = esc_html(trim($m[2]));
                return "\n\n<figure class=\"fos-preview-image-placeholder\">"
                    . "<div style=\"background:#f0f0f0;border:2px dashed #ccc;padding:40px 20px;text-align:center;border-radius:8px;\">"
                    . "<span style=\"font-size:14px;color:#888;\">&#128247; {$filename}</span><br>"
                    . "<span style=\"font-size:12px;color:#aaa;\">{$alt}</span>"
                    . "</div></figure>\n\n";
            },
            $content
        );

        // Markdown → HTML (no Gutenberg block wrapping for preview).
        $parsedown = new \Costudio_Vendor\Markdown();
        $parsedown->setSafeMode(false);
        $html = $parsedown->text($content);

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