<?php
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages go to PHP error log, never to HTML output.

/**
 * Costudio_Pipeline
 *
 * Orchestrates the seven-agent content production pipeline.
 *
 * AI settings are read from ContentOS Core Settings.
 *
 * @package Contentosapp_Studio
 * @since   3.0.0
 */

defined('ABSPATH') || exit;

class Costudio_Pipeline
{

    /** Agents that require live web search. */
    const SEARCH_AGENTS = [1, 3];

    /** Human-readable agent labels. */
    const AGENT_LABELS = [
        1 => 'Discoverer',
        2 => 'Strategist',
        3 => 'Researcher',
        4 => 'Writer',
        5 => 'Editorial Reviewer',
        6 => 'Visual Designer',
        7 => 'Social Media',
    ];

    /**
     * Returns the agent definitions for a specific pipeline version.
     */
    public static function get_agent_definitions( int $version = 7 ): array {
        if ( $version === 6 ) {
            return [
                1 => 'Discoverer',
                2 => 'Strategist',
                3 => 'Researcher',
                4 => 'Writer',
                5 => 'Visual Designer',
                6 => 'Social Media',
            ];
        }

        return self::AGENT_LABELS;
    }

    /**
     * Multiplier applied to user-facing word targets (`max` and midpoint)
     * when interpolating prompt placeholders.
     *
     * Current models no longer show the historical overshoot that motivated
     * a value below 1.0. Keeping the multiplier neutral prevents the prompt
     * from requesting less content than the range shown to the user while
     * preserving word count as editorial guidance rather than a hard floor.
     */
    const WORD_COUNT_CALIBRATION = 1.0;
    private const CONTEXT_MAX_CHARS_AGENT03_STEP2 = 6000;
    private const SEARCH_MD_MAX_CHARS_AGENT03 = 12000;

    private Costudio_AI_Client $client;
    private string $provider = 'gemini';
    private string $api_key  = '';
    private ?float $byok_attempt_temperature = null;
    private int $byok_attempt_thinking_budget = -1;
	private string $durable_worker_job_id = '';
	private int $durable_worker_deadline_seconds = 540;
	private float $durable_worker_started_at = 0.0;

    public function __construct(?array $byok_execution = null)
    {
		$this->client = $this->make_client($byok_execution);

		// Fatia 5 (2026-08-20): the repair seam in finalize_visual_plan_output()
		// was a stub with no registered callback — every failed VISUAL_PLAN
		// validation fell straight to the fallback, the single allowed repair
		// call was never attempted. This wires it to a real (bounded, best-
		// effort) call using the SAME client this pipeline instance already
		// resolved for the running production, so it respects whichever
		// mode/provider/model is actually configured without rebuilding that
		// resolution logic a second time.
		add_filter('costudio_visual_plan_repair_output', array($this, 'attempt_visual_plan_repair'), 10, 3);
    }

    // ── Factory ──────────────────────────────────────────────────────────────

    /**
     * Build the AI client reading settings from ContentOS Core (with D6 fallback).
     */
    private function make_client(?array $byok_execution = null): Costudio_AI_Client
    {
        $cts = costudio_settings();

        // Provider
		$provider = null !== $byok_execution
			? sanitize_key((string) ($byok_execution['provider'] ?? ''))
			: ($cts ? (string) $cts->get('ai_provider', 'gemini') : 'gemini');
        $this->provider = $provider;

        // API key
		$api_key = null !== $byok_execution
			? (string) ($byok_execution['api_key'] ?? '')
			: ($cts ? (string) $cts->get('ai_api_key', '') : '');
        $this->api_key = $api_key;

        // Explicit mode: 'contentos' -> use ContentOS Auto service quota regardless of saved BYOK key.
        // 'byok' or empty → use BYOK key if available, else fall back to ContentOS.
		$ai_mode = null !== $byok_execution ? 'byok' : ($cts ? (string) $cts->get('ai_mode', '') : '');

        // ContentOS Auto is available as a managed service with server-side quota.
        // Use ContentOS adapter when: mode is explicitly 'contentos', OR no BYOK key is set.
        $use_contentos = ($ai_mode === 'contentos')
            || (empty($api_key) && $ai_mode !== 'byok');

        if ($use_contentos) {
            $license_key = get_option('costudio_license_key', '');
            return new Costudio_ContentOS_Adapter($license_key, $provider);
        }

        // Model — fallback is provider-specific so an empty saved value never
        // sends a Gemini slug to OpenAI / Anthropic.
        $model_defaults = [
            'gemini' => 'gemini-2.5-flash',
            'openai' => 'gpt-4o',
            'claude' => 'claude-haiku-4-5-20251001',
        ];
        $model_default = $model_defaults[$provider] ?? 'gemini-2.5-flash';
		$model = null !== $byok_execution
			? sanitize_text_field((string) ($byok_execution['model'] ?? $model_default))
			: ($cts ? (string) $cts->get('ai_model', $model_default) : $model_default);

        switch ($provider) {
            case 'claude':
                return new Costudio_Claude_Adapter($api_key, $model);

            case 'openai':
                return new Costudio_OpenAI_Adapter($api_key, $model);

            default: // gemini
                // Native Google Search Grounding is no longer driven from the adapter:
                // when the user enables "Use ContentOS Real-time Search (native)" the
                // Pipeline pre-fetches results via Costudio_Search_Engine_ContentOS and
                // prepends them to the prompt. The constructor flag is therefore always
                // false here.
                return new Costudio_Gemini_Adapter($api_key, $model, false);
        }
    }

	/**
	 * Establish the durable queue boundary before run_step() performs any
	 * network-backed prompt/search preparation in a web worker.
	 */
	public function defer_to_durable_worker_if_needed(int $production_id, int $agent): void
	{
		if (
			$production_id < 1
			|| $agent < 1
			|| $agent > 7
			|| $this->client instanceof Costudio_ContentOS_Adapter
			|| ! class_exists('Costudio_BYOK_Gateway')
		) {
			return;
		}

		$model = method_exists($this->client, 'get_model') ? (string) $this->client->get_model() : $this->provider;
		Costudio_BYOK_Gateway::submit_pipeline_agent_if_needed(array(
			'production_id'   => $production_id,
			'agent_id'        => $agent,
			'provider'        => $this->provider,
			'model'           => $model ?: $this->provider,
			'api_key'         => $this->api_key,
			'deadline_seconds' => 540,
		));
	}

	/** Configure this pipeline instance as the owner of a claimed CLI job. */
	public function configure_durable_worker(
		string $job_id,
		callable $before_dispatch,
		callable $response_received,
		int $deadline_seconds = 540
	): void {
		if ( ! method_exists($this->client, 'set_durable_transport_hooks') ) {
			throw new RuntimeException('byok_adapter_missing_transport_hooks');
		}
		$this->durable_worker_job_id          = $job_id;
		$this->durable_worker_deadline_seconds = max(30, min(540, $deadline_seconds));
		$this->durable_worker_started_at       = microtime(true);
		$this->client->set_durable_transport_hooks($before_dispatch, $response_received);
		if (method_exists($this->client, 'disable_model_fallback')) {
			$this->client->disable_model_fallback();
		}
	}

    // ── Guarded AI call (rate limiter + circuit breaker) ────────────────────

    /**
     * Generate a one-off AI response outside the production pipeline.
     *
     * Used by lightweight admin interactions, such as Brand Voice preview,
     * that need the same provider selection, rate limiting and circuit breaker
     * as a normal production without creating a production record.
     */
    public function generate_ad_hoc(string $prompt, array $context = []): string
    {
        return $this->guarded_call(false, $prompt, $context, 0, 0, false);
    }

    /**
     * Call the AI client with rate limiting, circuit breaker, and usage tracking.
     *
     * @param  bool   $with_search    Whether to use generateWithSearch.
     * @param  string $prompt         The prompt to send.
     * @param  array  $context        Conversation context.
     * @param  int    $production_id  Production ID for usage tracking (0 = skip).
     * @param  int    $agent          Agent number for usage tracking (0 = edit call).
     * @return string                 Generated text.
     * @throws \Exception             On rate limit, circuit breaker, or API error.
     */
    /** Transient key for partial streaming output visible to the polling UI. */
    private const STREAM_TRANSIENT_PREFIX = 'costudio_stream_';

    /** How often (in bytes accumulated) to flush partial output to transient. */
    private const STREAM_FLUSH_INTERVAL = 200;

    private function guarded_call(bool $with_search, string $prompt, array $context = [], int $production_id = 0, int $agent = 0, bool $has_grounding = false): string
    {
        // Phase 3: the web runner submits/reads only. Once an attempt has been
        // accepted by the durable queue it remains authoritative even if the
        // operator rolls new traffic back to legacy mode.
        if (
            $production_id > 0
            && ! ($this->client instanceof Costudio_ContentOS_Adapter)
			&& '' === $this->durable_worker_job_id
			&& class_exists('Costudio_BYOK_Gateway')
        ) {
            $model = method_exists($this->client, 'get_model') ? (string) $this->client->get_model() : $this->provider;
            $durable = Costudio_BYOK_Gateway::maybe_generate([
                'production_id'  => $production_id,
                'agent_id'       => $agent,
                'provider'       => $this->provider,
                'model'          => $model ?: $this->provider,
                'api_key'        => $this->api_key,
                'prompt'         => $prompt,
                'context'        => $context,
                'temperature'    => $this->byok_attempt_temperature,
                'thinking_budget'=> $this->byok_attempt_thinking_budget,
                'deadline_seconds'=> 540,
                'has_grounding'  => $has_grounding,
            ]);
            if (null !== $durable) {
                return $durable;
            }
        }

        Costudio_Circuit_Breaker::check($this->provider, $this->api_key);
        Costudio_Rate_Limiter::wait($this->provider, $this->api_key);

		if ('' !== $this->durable_worker_job_id && method_exists($this->client, 'set_http_timeout')) {
			$elapsed   = (int) floor(microtime(true) - $this->durable_worker_started_at);
			$remaining = $this->durable_worker_deadline_seconds - $elapsed;
			if ($remaining < 5) {
				throw new RuntimeException('byok_deadline_exhausted_before_dispatch');
			}
			$provider_timeout = 'openai' === $this->provider ? 120 : 300;
			$this->client->set_http_timeout(max(1, min($provider_timeout, $remaining)));
		}

        $start_ms = (int) (microtime(true) * 1000);

        try {
            // Use streaming when the adapter supports it and we have a production context.
			$can_stream = '' === $this->durable_worker_job_id
				&& $production_id > 0
                && ! $with_search
                && method_exists($this->client, 'generate_streaming');

            if ($can_stream) {
                $stream_key     = self::STREAM_TRANSIENT_PREFIX . $production_id;
                $accumulated    = '';
                $unflushed      = 0;

                // Clear any stale partial output from a previous agent.
                set_transient($stream_key, wp_json_encode([
                    'agent'      => $agent,
                    'text'       => '',
                    'updated_at' => time(),
                ]), 600);

                $on_chunk = function (string $delta) use ($stream_key, $agent, &$accumulated, &$unflushed) {
                    $accumulated .= $delta;
                    $unflushed   += strlen($delta);

                    if ($unflushed >= self::STREAM_FLUSH_INTERVAL) {
                        set_transient($stream_key, wp_json_encode([
                            'agent'      => $agent,
                            'text'       => $accumulated,
                            'updated_at' => time(),
                        ]), 600);
                        $unflushed = 0;
                    }
                };

                $result = $this->client->generate_streaming($prompt, $context, $on_chunk);

                // Final flush.
                delete_transient($stream_key);
            } else {
                $result = $with_search
                    ? $this->client->generateWithSearch($prompt, $context)
                    : $this->client->generate($prompt, $context);
            }

            Costudio_Circuit_Breaker::record_success($this->provider, $this->api_key);

            // Log successful call.
            $latency_s = round(((int) (microtime(true) * 1000) - $start_ms) / 1000, 1);
            Costudio_Logger::debug(
                sprintf('AI call completed in %ss (%s)', $latency_s, $this->provider),
                ['agent' => $agent, 'provider' => $this->provider, 'with_search' => $with_search],
                $production_id,
                $agent
            );

            // Record token usage if the adapter supports it.
            $latency_ms = (int) (microtime(true) * 1000) - $start_ms;
            if ($production_id > 0) {
                $usage = method_exists($this->client, 'get_last_usage') ? $this->client->get_last_usage() : [];
                $model = method_exists($this->client, 'get_model') ? $this->client->get_model() : '';
                $timing = $this->resolve_usage_timing( $production_id, $agent, $start_ms );
                 
				$usage_row = [
                    'production_id' => $production_id,
                    'agent'         => $agent,
                    'provider'      => $this->provider,
                    'model'         => $model ?: $this->provider,
                    'input_tokens'  => $usage['input_tokens'] ?? 0,
                    'output_tokens' => $usage['output_tokens'] ?? 0,
                    'latency_ms'    => $timing['elapsed_ms'] ?: $latency_ms,
                    'started_at'    => $timing['started_at'],
                    'grounding'     => $has_grounding,
				];
				if ('' !== $this->durable_worker_job_id) {
					$usage_row['byok_job_id'] = $this->durable_worker_job_id;
				}
				Costudio_Usage::record($usage_row);
            }

            return $result;
        } catch (Costudio_Grounding_Exception $e) {
            // Grounding exceptions are not provider failures — re-throw as-is.
            Costudio_Logger::warn('Grounding fallback triggered', ['provider' => $this->provider], $production_id, $agent);
            throw $e;
        } catch (\Throwable $e) {
            $message = $e->getMessage();
            Costudio_Logger::error('AI call failed: ' . $message, ['provider' => $this->provider, 'agent' => $agent], $production_id, $agent);

            // Check for rate-limit responses (HTTP 429) and record Retry-After.
            if (strpos($message, '429') !== false) {
                // If it's a 429, we likely hit a quota limit. Sync with server to update UI state.
                if (method_exists('Costudio_Standalone_License', 'sync_remote_usage')) {
                    Costudio_Standalone_License::sync_remote_usage();
                }

                $retry_after = 60; // Default 60s if no header parsed.
                if (preg_match('/retry.after[:\s]+(\d+)/i', $message, $m)) {
                    $retry_after = (int) $m[1];
                }
                Costudio_Rate_Limiter::record_retry_after($this->provider, $this->api_key, $retry_after);

                // BYOK 429 → surface a contextual upsell on the License page.
                // ContentOS Auto runs on managed infra with no per-user provider
                // rate limits. Single transient, no parallel flow; self-expires.
                if (! ($this->client instanceof Costudio_ContentOS_Adapter)) {
                    set_transient('costudio_byok_rate_limited', [
                        'provider'    => $this->provider,
                        'retry_after' => $retry_after,
                        'at'          => time(),
                    ], 30 * MINUTE_IN_SECONDS);
                }
            }

            // Record failure for circuit breaker (5xx errors and timeouts).
            if (preg_match('/HTTP\s+5\d{2}|timeout|timed?\s*out/i', $message)) {
                Costudio_Circuit_Breaker::record_failure($this->provider, $this->api_key);
            }

            throw $e;
        }
    }

    /**
     * Resolve a stable attempt boundary claimed by Costudio_Runner.
     *
     * Direct/ad-hoc calls may not have a production timing record, so the
     * original in-process start remains a safe fallback.
     *
     * @return array{started_at:string,elapsed_ms:int}
     */
    private function resolve_usage_timing( int $production_id, int $agent, int $fallback_start_ms ): array {
        $timing = Costudio_Production::get_agent_attempt_timing( $production_id, $agent );
        if ( ! empty( $timing['started_at'] ) && (int) $timing['elapsed_ms'] > 0 ) {
            return array(
                'started_at' => (string) $timing['started_at'],
                'elapsed_ms' => (int) $timing['elapsed_ms'],
            );
        }

        return array(
            'started_at' => gmdate( 'Y-m-d H:i:s', (int) floor( $fallback_start_ms / 1000 ) ),
            'elapsed_ms' => max( 0, (int) round( microtime( true ) * 1000 ) - $fallback_start_ms ),
        );
    }

    // ── Web search orchestration ─────────────────────────────────────────────

    /**
     * Determine whether/how web search is available for this production —
     * the SAME decision `pipeline_search()` (Agent 03) already made inline.
     * Extracted (Item 3, subitem 3B prep) so the video-discovery orchestration
     * can reuse the exact same policy instead of re-deriving it independently
     * — two copies of "is search on, native or external" drifting apart is
     * exactly the class of bug that produced 2 blockers in the 3A review.
     *
     * NOT side-effect-free: `contentos_native_search_enabled()` can trigger a
     * one-time settings migration (`maybe_migrate_contentos_auto_grounding()`,
     * which persists via `update_option()`) the first time it runs after
     * upgrade. No network call either way, and no behavioural change from
     * calling this — just documenting it honestly rather than claiming "pure".
     *
     * Static (no instance state used) and public — Costudio_Video_Discovery
     * (Item 3, subitem 3B) calls this directly, from outside the pipeline,
     * to route its own search exactly like pipeline_search() does.
     *
     * @return array{enabled:bool,mode:string,ai_mode:string,provider:string,reason:string}
     *               mode is one of 'native' | 'external' | 'none'.
     */
    public static function search_policy_for(array $inputs): array
    {
        $cts     = function_exists('costudio_settings') ? costudio_settings() : null;
        $ai_mode = ! empty($inputs['execution_ai_mode'])
            ? (string) $inputs['execution_ai_mode']
            : ($cts && method_exists($cts, 'resolve_ai_mode')
                ? (string) $cts->resolve_ai_mode()
                : ( $cts ? (string) $cts->get('ai_mode', 'contentos') : 'contentos' ));

        $native_on = $cts && method_exists($cts, 'contentos_native_search_enabled')
            ? (bool) $cts->contentos_native_search_enabled($ai_mode)
            : ( 'byok' !== $ai_mode && (bool) get_option('costudio_enable_web_search', true) && ( $cts ? (bool) $cts->get('search_grounding', true) : true ) );

        if ($native_on) {
            return [
                'enabled'  => true,
                'mode'     => 'native',
                'ai_mode'  => $ai_mode,
                'provider' => '',
                'reason'   => '',
            ];
        }

        // Native OFF → external provider, only in BYOK mode. In ContentOS Auto,
        // turning native search off means "no search", not "spend external API".
        if ($ai_mode !== 'byok') {
            return [
                'enabled'  => false,
                'mode'     => 'none',
                'ai_mode'  => $ai_mode,
                'provider' => '',
                'reason'   => 'native_off_auto_mode',
            ];
        }

        $external_provider = $cts ? (string) $cts->get('search_api_provider', '') : '';
        if ($external_provider === '') {
            return [
                'enabled'  => false,
                'mode'     => 'none',
                'ai_mode'  => $ai_mode,
                'provider' => '',
                'reason'   => 'no_external_provider_configured',
            ];
        }

        return [
            'enabled'  => true,
            'mode'     => 'external',
            'ai_mode'  => $ai_mode,
            'provider' => $external_provider,
            'reason'   => '',
        ];
    }

    /**
     * Run web search for the current agent and return the markdown blob to be
     * prepended to the prompt. Centralised here (not in the adapters) so the
     * "Use ContentOS Real-time Search (native)" toggle works consistently
     * across every AI provider.
     *
     * @param int   $agent
     * @param array $inputs        Production inputs (must include 'keyword').
     * @param int   $production_id For logging context.
     * @return string              Markdown blob, or '' when no search ran or it failed.
     */
    private function pipeline_search(int $agent, array $inputs, int $production_id): string
    {
        // Reset the per-run "structured grounding persisted" marker at the start of
        // the Researcher's search, so it reflects ONLY this run/reprocess. It is
        // re-set below iff structured sources are actually persisted this run — so a
        // free-tier Gemini reprocess that returns no chunks correctly falls back to
        // the legacy scraper instead of inheriting a stale marker (≤1h old).
        if ($agent === 3 && $production_id > 0) {
            delete_transient('costudio_grounded_persisted_' . $production_id);
        }

        $cts    = function_exists('costudio_settings') ? costudio_settings() : null;
        $policy = self::search_policy_for($inputs);
        $ai_mode   = $policy['ai_mode'];
        $native_on = ('native' === $policy['mode']);
        $raw_search_grounding = $cts
            ? ( $cts->has('search_grounding') ? (bool) $cts->get('search_grounding', false) : null )
            : null;

        $query     = trim((string) ($inputs['keyword'] ?? ''));
        $lang_name = trim((string) ($inputs['lang_name'] ?? ''));
        // Prepended to successful search results so the LLM reads the language
        // preference before processing source titles and URLs.
        $lang_note = $lang_name !== ''
            ? "> **Language note:** This content is being produced in **{$lang_name}**. "
                . "When selecting sources to cite, prefer sources written in {$lang_name}. "
                . "Sources in other languages may only be used when no equivalent in {$lang_name} is available.\n\n"
            : '';

        self::pipeline_log('info', 'pipeline_search.entry', array(
            'production_id'       => $production_id,
            'agent'               => $agent,
            'query'               => $query,
            'query_empty'         => $query === '',
            'native_on'           => $native_on,
            'ai_mode'             => $ai_mode,
            'global_web_search'   => (bool) get_option('costudio_enable_web_search', true),
            'raw_search_grounding' => $raw_search_grounding,
            'service_key_present' => (string) get_option('costudio_license_key', '') !== '',
        ));

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

        if ($native_on) {
            if (! class_exists('Costudio_Search_Engine_ContentOS')) {
                self::pipeline_log('warn', 'pipeline_search.contentos_class_missing', array(
                    'production_id' => $production_id,
                    'agent'         => $agent,
                ));
                return '';
            }

            self::pipeline_log('info', 'pipeline_search.contentos.start', array(
                'production_id' => $production_id,
                'agent'         => $agent,
                'query'         => $query,
            ));

            $start_ms = (int) (microtime(true) * 1000);
            $engine   = new Costudio_Search_Engine_ContentOS();
            $result   = $engine->search($query, array('production_id' => $production_id));
            $latency  = (int) (microtime(true) * 1000) - $start_ms;

            self::pipeline_log('info', 'pipeline_search.contentos.result', array(
                'production_id' => $production_id,
                'agent'         => $agent,
                'ok'            => ! empty($result['ok']),
                'gated'         => ! empty($result['gated']),
                'sources_count' => is_array($result['sources'] ?? null) ? count($result['sources']) : 0,
                'error'         => (string) ($result['error'] ?? ''),
                'latency_ms'    => $latency,
                'model_used'    => (string) ($result['model_used'] ?? ''),
            ));

            if (! empty($result['gated'])) {
                self::pipeline_log('info', 'pipeline_search.gated', array(
                    'production_id' => $production_id,
                    'agent'         => $agent,
                    'reason'        => (string) ($result['reason'] ?? 'search_unavailable_on_starter'),
                    'message'       => 'Web search skipped — available on Pro plan',
                ));
                return '';
            }

            if (! empty($result['ok'])) {
                // Shared with the BYOK external-provider path below: dedupe vs the
                // user's externals (agent 3), persist sources_json, and build the
                // numbered ## INDEXED SOURCES block.
                return $lang_note . $this->persist_and_format_grounded($agent, $production_id, $result);
            }

            if (! empty($result['error'])) {
                self::pipeline_log('warn', 'pipeline_search.contentos.failed', array(
                    'production_id' => $production_id,
                    'agent'         => $agent,
                    'error'         => $result['error'],
                ));
            }
            // Native ON but failed — degrade gracefully, do NOT silently fall
            // back to the user's BYOK external provider (could surprise their bill).
            return '';
        }

        // Native OFF → external provider, only in BYOK mode. In ContentOS Auto,
        // turning native search off means "no search", not "spend external API".
        if ('native_off_auto_mode' === $policy['reason']) {
            self::pipeline_log('info', 'pipeline_search.native_off.auto_mode_skip', array(
                'production_id' => $production_id,
                'agent'         => $agent,
                'ai_mode'       => $ai_mode,
            ));
            return '';
        }

        $external_provider = $policy['provider'];

        self::pipeline_log('info', 'pipeline_search.native_off', array(
            'production_id'     => $production_id,
            'agent'             => $agent,
            'ai_mode'           => $ai_mode,
            'external_provider' => $external_provider,
        ));

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

        $engine = new Costudio_Search_Engine();
        $result = $engine->search_structured($query);

        if (empty($result['ok'])) {
            return '';
        }

        if (class_exists('Costudio_Telemetry')) {
            Costudio_Telemetry::queue('search.performed', [
                'production_id'  => $production_id,
                'production_uid' => class_exists('Costudio_Production') ? Costudio_Production::tracking_uid_for($production_id) : '',
                'agent_num'      => $agent,
                'type'           => (string) ($inputs['article_type'] ?? ''),
                'locale'         => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
                'category'       => 'search',
                'provider'       => (string) ($result['provider'] ?? $external_provider),
                'model'          => (string) ($result['model'] ?? ''),
                'model_used'     => (string) ($result['model_used'] ?? ($result['model'] ?? $external_provider)),
                'sources_count'  => is_array($result['sources'] ?? null) ? count($result['sources']) : 0,
            ]);
        }

        // Structured providers (Exa / Serper / Tavily) → identical flow to native:
        // persist sources_json + build the numbered ## INDEXED SOURCES block so the
        // discovered sources become citable {{ref:N}} candidates (equal weight to
        // the user's externals; the renderer keeps the "user links always appended"
        // rule as the only difference).
        if (! empty($result['sources'])) {
            return $lang_note . $this->persist_and_format_grounded($agent, $production_id, $result);
        }

        // Providers without structured sources (Gemini → Vertex redirects) → raw
        // context string exactly as before. No regression; structured Gemini is a
        // planned follow-up (parse grounding metadata + resolve Vertex redirects).
        return $lang_note . (string) ($result['fallback_md'] ?? '');
    }

    /**
     * Dedupe grounded sources vs the user's externals (agent 3), persist them to
     * `sources_json`, and format the numbered `## INDEXED SOURCES` block. Shared by
     * the native (ContentOS) and BYOK external-provider search paths so both feed
     * the citable `{{ref:N}}` pool identically.
     *
     * @param int   $agent
     * @param int   $production_id
     * @param array $result Structured result carrying a 'sources' list ({url,title,snippet}).
     * @return string Prompt markdown (the caller prepends the language note).
     */
    private function persist_and_format_grounded(int $agent, int $production_id, array $result): string
    {
        $sources_persisted = false;
        $result_for_md     = $result;

        if (
            $agent === 3
            && $production_id > 0
            && ! empty($result['sources'])
            && is_array($result['sources'])
        ) {
            // Cross-dedupe vs the user's own external links: a URL the user already
            // supplied appears ONCE in the citable pool — as the USER's entry (which
            // carries their description and is read for content downstream). Doing it
            // before set_sources_json keeps the {{ref:N}} offset consistent between
            // the prompt and the References Renderer.
            $user_ext_urls = $this->user_external_url_set($production_id);
            $dedup = [];
            $seen  = [];
            foreach ($result['sources'] as $src) {
                $u = isset($src['url']) ? trim((string) $src['url']) : '';
                if ($u === '' || isset($seen[$u])) {
                    continue;
                }
                if (isset($user_ext_urls[self::normalize_url_for_match($u)])) {
                    continue;
                }
                $seen[$u] = true;
                $dedup[]  = $src;
            }
            Costudio_Production::set_sources_json($production_id, $dedup);
            $sources_persisted = true;
            // Mark that structured grounding populated the pool THIS run, so the
            // post-agent legacy prose scraper is skipped (dynamic gate — works for
            // providers whose "structured-ness" is conditional, e.g. Gemini).
            set_transient('costudio_grounded_persisted_' . $production_id, 1, HOUR_IN_SECONDS);
            // Number the INDEXED SOURCES block from the SAME deduped list that was
            // persisted, so the grounded {{ref:N}} numbering is identical to the
            // renderer pool (and the user-external offset lands exactly after it).
            $result_for_md['sources'] = $dedup;
        }

        $markdown = Costudio_Search_Engine_ContentOS::to_prompt_markdown($result_for_md);

        // Bound a very large grounded-search block so async jobs stay within runtime.
        if ($agent === 3 && strlen($markdown) > self::SEARCH_MD_MAX_CHARS_AGENT03) {
            $markdown = substr($markdown, 0, self::SEARCH_MD_MAX_CHARS_AGENT03)
                . "\n\n---\nNOTE: Results truncated for performance.\n";
        }

        self::pipeline_log('info', 'pipeline_search.markdown_built', array(
            'production_id'     => $production_id,
            'agent'             => $agent,
            'sources_persisted' => $sources_persisted,
            'markdown_chars'    => strlen($markdown),
            'has_indexed_block' => strpos($markdown, '## INDEXED SOURCES — cite with {{ref:N}} placeholders') !== false,
        ));

        return $markdown;
    }

    /**
     * Diagnostic logger for the pipeline. Writes a single line to PHP error_log
     * (visible in the plugin's error_log file) using the same prefix style as
     * Publisher/Image so all Contentosapp Studio events appear together in the log.
     */
    private static function pipeline_log(string $level, string $event, array $payload = array()): void
    {
        if ($level === 'debug' && function_exists('costudio_debug_enabled') && ! costudio_debug_enabled()) {
            return;
        }
        if ( function_exists( 'costudio_debug_enabled' ) && costudio_debug_enabled() && function_exists( 'costudio_log_debug' ) ) {
            costudio_log_debug( 'pipeline.' . $event, $payload );
        }
    }

    /**
     * pipeline_log() above is intentionally quiet: it routes through
     * costudio_log_debug(), which only reaches the PHP error log and only
     * when WP_DEBUG-style diagnostics are explicitly opted in — fine for
     * routine milestones, but it means an operator gets no visibility at all
     * on a normal install. The visual-plan pipeline (Item Ciclo 3) is new and
     * genuinely worth watching, so its events go through Costudio_Logger
     * instead — the same DB-backed, Technical-Logs-panel-visible path used
     * by pipeline.video_discovery.* — with a fallback to pipeline_log() only
     * if Costudio_Logger somehow isn't available.
     */
    private static function visual_log( string $level, string $event, array $context, int $production_id = 0 ): void
    {
        $method = 'warning' === $level ? 'warn' : $level;
        if ( class_exists( 'Costudio_Logger' ) && method_exists( 'Costudio_Logger', $method ) ) {
            call_user_func( array( 'Costudio_Logger', $method ), 'pipeline.' . $event, $context, $production_id > 0 ? $production_id : null, 6 );
            return;
        }
        self::pipeline_log( $level, $event, $context );
    }

    // ── Search & citation directives (shared by local + secure mode) ────────

    /**
     * Run grounded search for the current agent and return both the markdown
     * blob to be injected into the prompt/context and a flag indicating
     * whether structured sources were persisted to `sources_json`.
     *
     * Centralised here so Secure Mode (ContentOS Cloud) and BYOK share the
     * same grounding behaviour — the {{ref:N}} system depends on
     * `sources_json` being populated for the production.
     *
     * @param int   $agent
     * @param array $inputs
     * @param int   $production_id
     * @param bool  $do_search   Computed once by the caller from local settings.
     * @return array{md: string, has_sources: bool}
     */
    private function prepare_search_payload(int $agent, array $inputs, int $production_id, bool $do_search): array
    {
        $md = '';
        if ($do_search) {
            $md = $this->pipeline_search($agent, $inputs, $production_id);
        }

        // Re-read the production so we observe whatever pipeline_search
        // persisted. set_sources_json() runs only for agent 3, but for
        // agent 4 we still need to know whether sources are now available.
        // grounded_count is the size of the renderer's grounded pool and is
        // used as the {{ref:N}} offset for user-external citations.
        $grounded_count = $this->grounded_sources_count($production_id);

        return array(
            'md'             => (string) $md,
            'has_sources'    => $grounded_count > 0,
            'grounded_count' => $grounded_count,
        );
    }

    /**
     * Number of grounded sources persisted to `sources_json` (fresh from DB).
     * Equals the References Renderer's `user_external_offset` — the count of
     * citable entries that precede user-provided externals in the {{ref:N}} pool.
     */
    private function grounded_sources_count(int $production_id): int
    {
        if ($production_id <= 0) {
            return 0;
        }
        $prod = Costudio_Production::get_by_id($production_id);
        if (! $prod || empty($prod->sources_json)) {
            return 0;
        }
        $decoded = json_decode((string) $prod->sources_json, true);
        return is_array($decoded) ? count($decoded) : 0;
    }

    /**
     * Set of the production's user-provided external link URLs, keyed by a
     * normalized form for "same page" matching. Used to dedupe grounded search
     * hits against the user's own links (the user's entry wins).
     *
     * @return array<string,bool>
     */
    private function user_external_url_set(int $production_id): array
    {
        $set = array();
        if ($production_id <= 0) {
            return $set;
        }
        $prod = Costudio_Production::get_by_id($production_id);
        if (! $prod || empty($prod->links_internos)) {
            return $set;
        }
        $parsed = Costudio_Pipeline_Links::parse_links_internos((string) $prod->links_internos);
        foreach ((array) ($parsed['external'] ?? array()) as $item) {
            $u = isset($item['url']) ? trim((string) $item['url']) : '';
            if ($u !== '') {
                $set[self::normalize_url_for_match($u)] = true;
            }
        }
        return $set;
    }

    /**
     * Normalize a URL for "same page" equality: drop scheme, leading www, query,
     * fragment and trailing slash; lowercase the host. Lenient by design so a
     * user link and a grounded hit pointing at the same page collapse to one.
     */
    public static function normalize_url_for_match(string $url): string
    {
        $url   = trim($url);
        $parts = wp_parse_url($url);
        if (! $parts || empty($parts['host'])) {
            return strtolower($url);
        }
        $host = strtolower(preg_replace('/^www\./', '', (string) $parts['host']));
        $path = isset($parts['path']) ? rtrim((string) $parts['path'], '/') : '';
        return $host . $path;
    }

    /**
     * True when web search is OFF for ContentOS Auto (so the server must NOT
     * auto-ground). Secure Mode runs only in Auto mode, where native real-time
     * search is governed by `search_grounding`; the master `costudio_enable_web_search`
     * kills it entirely. Fails open (returns false) when settings are unavailable.
     */
    private function web_search_effectively_disabled(): bool
    {
        $cts = function_exists('costudio_settings') ? costudio_settings() : null;
        if (! $cts) {
            return ! (bool) get_option('costudio_enable_web_search', true);
        }
        if (method_exists($cts, 'contentos_native_search_enabled')) {
            return ! (bool) $cts->contentos_native_search_enabled();
        }

        if (! (bool) get_option('costudio_enable_web_search', true)) {
            return true;
        }

        return ! (bool) $cts->get('search_grounding', true);
    }

    private function production_has_citable_sources(int $production_id): bool
    {
        if ($production_id <= 0) {
            return false;
        }

        $prod = Costudio_Production::get_by_id($production_id);
        if (! $prod) {
            return false;
        }

        if ($this->production_has_grounded_sources($production_id)) {
            return true;
        }

        if (! empty($prod->links_internos)) {
            $parsed = Costudio_Pipeline_Links::parse_links_internos((string) $prod->links_internos);
            foreach ((array) ($parsed['external'] ?? array()) as $item) {
                if (! empty($item['url'])) {
                    return true;
                }
            }
        }

        return false;
    }

    private function production_has_grounded_sources(int $production_id): bool
    {
        if ($production_id <= 0) {
            return false;
        }

        $prod = Costudio_Production::get_by_id($production_id);
        if (! $prod || empty($prod->sources_json)) {
            return false;
        }

        $decoded = json_decode((string) $prod->sources_json, true);
        return is_array($decoded) && ! empty($decoded);
    }

    /**
     * Build the SYSTEM OVERRIDE block for citation rules.
     *
     * Cenário A (has_sources): Writer cites grounded/user-external sources
     * via natural Markdown anchors that use `{{ref:N}}` as the URL placeholder;
     * the renderer appends the unified Referências section at publish time.
     * Manual `## Referências` is
     * forbidden — a defence-in-depth strip in the publisher removes it
     * even if the LLM ignores this rule.
     *
     * Cenário B (no sources): Writer uses standard Markdown links. The
     * renderer still emits a Referências section if the production has
     * user-supplied internal/external links; otherwise no section is
     * appended. Manual `## Referências` from the LLM is always stripped.
     *
     * @param int $agent
     * @param int $production_id
     * @return string  Empty string when the agent doesn't need citation rules.
     */
    private function build_citation_directives(int $agent, int $production_id): string
    {
        if (! in_array($agent, [3, 4], true)) {
            return '';
        }

        $has_sources = $this->production_has_citable_sources($production_id);

        if (! $has_sources) {
            $block  = "## SYSTEM OVERRIDE: CITATIONS (Cenário B — no external sources available)\n";
            $block .= "No verified external sources are available for this task (web search is disabled or returned nothing). IGNORE ALL previous instructions regarding the `{{ref:N}}` placeholder system AND any instruction to cite external sources.\n";
            $block .= "ABSOLUTE RULE — NO EXTERNAL CITATIONS OR URLs: Do NOT cite, link to, or invent ANY external source or URL (`http://...`, `https://...`). You have NO verified sources, so any external URL you write would be fabricated — a non-existent or wrong link is a critical failure. Never output an external link in this scenario.\n";
            $block .= "Do NOT attribute specific statistics, study names, quotes, dates, or institutions you cannot verify. Write authoritative, useful prose from general knowledge WITHOUT fabricating named sources or precise figures — prefer general phrasing (e.g. `studies suggest`, `especialistas recomendam`, `é amplamente reconhecido que`) over a fabricated `[a 2024 Harvard study](https://...)`.\n";
            $block .= "Internal links ONLY: if a `LINKS INTERNOS` block is provided, you MAY weave 1–2 of those internal URLs as standard Markdown `[descriptive anchor](internal URL)`, using each URL EXACTLY as given. These are the ONLY links allowed here. If no internal links are provided, the article has no links at all — that is correct.\n";
            $block .= "DO NOT write a `## Referências`, `## References`, or `## Referencias` section. The CMS assembles the bibliography from the production's metadata. Manual reference lists are stripped before publishing.\n";
            return $block;
        }

        if ($agent === 4) {
            $block  = "## SYSTEM OVERRIDE: INDEXED EXTERNAL SOURCES (Cenário A — ACTIVE)\n";
            $block .= "Structured indexed sources ARE available (`sources_json` + user-provided externals). Rules below OVERRIDE conflicting base instructions:\n\n";
            $block .= "- Do NOT write `## Referências`, `## References`, or `## Referencias`. The CMS appends the validated bibliography.\n";
            $block .= "- Do NOT paste raw external `https://...` URLs for sources that came from INDEXED SOURCES.\n";
            $block .= "- CITATION FORMAT — CRITICAL: cite ONLY as `[descriptive factual phrase]({{ref:N}})`. The anchor text MUST be a real descriptive phrase, NOT a comma, a number, or empty.\n";
            $block .= "  ✅ CORRECT: `[marital satisfaction drops 15% on average]({{ref:2}})`\n";
            $block .= "  ❌ WRONG: `satisfaction drops 15%. {{ref:2}}` — bare placeholder, renders as source title mid-sentence\n";
            $block .= "  ❌ WRONG: `[, {{ref:2}}]` or `[{{ref:2}}]` — no anchor text\n";
            $block .= "- Minimum 2 anchored citations when the citable pool has ≥2 entries.\n";
            $block .= "- Internal links from LINKS INTERNOS / available internal URLs stay as normal Markdown: `[anchor](full URL)`.\n";
            return $block;
        }

        // Agent 3 with sources: no override needed; the prompt already
        // instructs the Researcher to emit `{{ref:N}}` placeholders.
        return '';
    }

    /**
     * Translate a Secure Mode adapter exception into a user-facing error string.
     *
     * The raw exception messages from Costudio_ContentOS_Adapter expose backend
     * detail (Cloudflare codes, internal fingerprints, etc.) that is unhelpful
     * for the end user staring at the production card. This routine matches
     * known failure shapes and returns a localized, actionable message while
     * still preserving the original error tail for support.
     *
     * @param string $error_msg Raw exception message from the adapter.
     * @param int    $agent     Agent number that failed.
     */
    private static function build_secure_mode_failure_message(string $error_msg, int $agent): string
    {
		$low = strtolower($error_msg);

		// Durable queue V2 deliberately does not retry an ambiguous transport
		// result because the provider may already have consumed the request. Keep
		// this message intact instead of appending the generic reprocess suffix.
		if (strpos($low, 'not repeated automatically') !== false && strpos($low, 'provider usage may have occurred') !== false) {
			return sprintf(
				/* translators: %d: agent number that lost provider confirmation */
				__('O Agente %d não recebeu a confirmação final do provedor de IA. A solicitação não foi repetida para evitar uso duplicado. O provedor pode ter processado a tentativa; use "Tentar agente novamente" somente se quiser iniciar uma nova geração.', 'contentosapp-studio'),
				$agent
			);
		}

        if (self::is_managed_provider_billing_error($low)) {
            return sprintf(
                /* translators: %d: agent number that failed */
                __('Agente %d falhou: o ContentOS Auto está temporariamente indisponível porque o provedor de IA gerenciado precisa de atenção. Esta tentativa não foi contabilizada. Aguarde alguns minutos e use "Reprocessar daqui", ou mude para BYOK em Configurações → IA.', 'contentosapp-studio'),
                $agent
            );
        }

        // Cloudflare WAF managed challenge (HTTP 403 after sustained 5xx).
        if (strpos($low, 'waf') !== false || strpos($low, 'attention required') !== false || strpos($low, 'blocked by cloudflare') !== false) {
            return sprintf(
                /* translators: %d: agent number that failed */
                __('Agente %d falhou: a Cloudflare bloqueou temporariamente este servidor após várias falhas seguidas no servidor ContentOS. Aguarde 5–10 minutos e use "Reprocessar daqui". Se persistir, troque para um provedor BYOK (Gemini / Claude / OpenAI) em Configurações → IA.', 'contentosapp-studio'),
                $agent
            );
        }

        // Backend unreachable / 5xx exhaustion / origin overloaded.
        if (
            strpos($low, 'unreachable') !== false
            || strpos($low, 'overloaded') !== false
            || strpos($low, 'bad gateway') !== false
            || strpos($low, '502') !== false
            || strpos($low, '503') !== false
            || strpos($low, '504') !== false
            || strpos($low, 'cloudflare') !== false
        ) {
            return sprintf(
                /* translators: %d: agent number that failed */
                __('Agente %d falhou: o servidor ContentOS está sobrecarregado ou indisponível no momento. Aguarde alguns minutos e use "Reprocessar daqui" para retomar a produção. Como alternativa, troque para um provedor BYOK (Gemini / Claude / OpenAI) em Configurações → IA.', 'contentosapp-studio'),
                $agent
            );
        }

        // Stale job / job timeout.
        if (strpos($low, 'stale') !== false || strpos($low, 'did not complete within') !== false || strpos($low, 'timed out') !== false) {
            return sprintf(
                /* translators: %d: agent number */
                __('Agente %d falhou: a geração de IA excedeu o tempo limite no servidor. Tente novamente em "Reprocessar daqui".', 'contentosapp-studio'),
                $agent
            );
        }

        // Empty payload / HTTP 200 with empty output.
        if (strpos($low, 'empty') !== false && strpos($low, 'output') !== false) {
            return sprintf(
                /* translators: %d: agent number */
                __('Agente %d falhou: o servidor retornou uma resposta vazia. Tente "Reprocessar daqui" em alguns instantes.', 'contentosapp-studio'),
                $agent
            );
        }

        // Free plan lifetime premium trial exhausted (server returns 'trial_exhausted').
        if (strpos($low, 'trial_exhausted') !== false) {
            return __('Your free ContentOS Auto trial (3 premium articles) is used. Keep creating unlimited with your own key (BYOK) in Settings → AI, or upgrade to a paid plan in the License menu.', 'contentosapp-studio');
        }

        // ContentOS Auto service authentication rejection.
        if (strpos($low, 'license') !== false || strpos($low, '401') !== false || strpos($low, '403') !== false) {
            return __('ContentOS Auto service authentication failed. Check the managed-service license in the License menu, or switch to BYOK with your own provider key.', 'contentosapp-studio');
        }

        // Quota / rate limit.
        if (strpos($low, 'production_call_limit') !== false || strpos($low, 'per-article') !== false) {
            return __('Limite de segurança por artigo atingido. Aguarde algumas horas antes de reprocessar este mesmo artigo ou entre em contato com o suporte.', 'contentosapp-studio');
        }

        if (strpos($low, 'quota') !== false || strpos($low, 'limit reached') !== false || strpos($low, '429') !== false) {
            return __('ContentOS Auto managed-service quota reached. BYOK remains available with your own provider key.', 'contentosapp-studio');
        }

        // Generic fallback — keep a short tail of the raw message for support.
        $tail = mb_substr($error_msg, 0, 200);
        return sprintf(
            /* translators: 1: agent number, 2: short error tail */
            __('Agente %1$d falhou na execução remota: %2$s. Use "Reprocessar daqui" para tentar novamente.', 'contentosapp-studio'),
            $agent,
            $tail
        );
    }

    private static function is_managed_provider_billing_error(string $message): bool
    {
        $needles = [
            'prepayment credits are depleted',
            'billing#prepay',
            'manage your project and billing',
            'project and billing',
            'managed ai provider quota or billing credits are exhausted',
            'managed ai provider needs attention',
        ];

        foreach ($needles as $needle) {
            if (strpos($message, $needle) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Queue the production.started telemetry event before either Secure Mode or
     * BYOK execution can short-circuit the local prompt path.
     */
    private function queue_production_started_once(int $production_id, object $production, array &$inputs, string $article_type): void
    {
        if ($production_id <= 0 || ! class_exists('Costudio_Telemetry')) {
            return;
        }

        if (! empty($inputs[Costudio_Production::TELEMETRY_STARTED_INPUT])) {
            return;
        }

        if (method_exists('Costudio_Telemetry', 'is_enabled') && ! Costudio_Telemetry::is_enabled()) {
            return;
        }

        $production_uid = Costudio_Production::tracking_uid_for($production_id);
        if ($production_uid !== '') {
            $inputs[Costudio_Production::TRACKING_UID_INPUT] = $production_uid;
        }

        $extensao = $inputs['extensao'] ?? '2000-2600';
        $limits   = $this->parse_extensao($extensao);

        Costudio_Telemetry::queue('production.started', [
            'production_id'  => $production_id,
            'production_uid' => $production_uid,
            'type'           => $article_type,
            'locale'         => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
            'target_words'   => $limits['alvo'],
            'has_pilar_url'  => ! empty($inputs['pilar_url']),
        ]);

        $inputs[Costudio_Production::TELEMETRY_STARTED_INPUT] = time();
        Costudio_Production::update_inputs($production_id, $inputs);
        $production->inputs = wp_json_encode($inputs);
    }

    // ── Main entry point ─────────────────────────────────────────────────────

    /**
     * Run a single step of the pipeline and return the next step number.
     *
     * @param  int $agent         Agent number (1-7), or max+1 for publishing.
     * @param  int $production_id Production record ID.
     * @return int|null           Next agent number, or null if finished.
     */
    public function run_step(int $agent, int $production_id): ?int
    {
        $production = Costudio_Production::get_by_id($production_id);

        if (! $production) {
            return null;
        }

        // ── Status Guard ─────────────────────────────────────────────────────
        // Ensure we don't start a step if the user has paused or cancelled.
		if (
			'cancelled' === $production->status
			|| ( 'paused' === $production->status && '' === $this->durable_worker_job_id )
		) {
            return null;
        }

        $version    = $production->get_pipeline_version();
        $max_agents = ($version === 7) ? 7 : 6;

        $inputs = json_decode($production->inputs ?? '{}', true);
        if (! is_array($inputs)) {
            $inputs = [];
        }

        $effective_ai_mode = ($this->client instanceof Costudio_ContentOS_Adapter) ? 'contentos' : 'byok';
        if ((string) ($inputs['execution_ai_mode'] ?? '') !== $effective_ai_mode) {
            $inputs['execution_ai_mode'] = $effective_ai_mode;
            $inputs['execution_provider'] = $this->provider;
            $production->set_inputs($inputs);
        }

        // ── Brand Voice Snapshot (Only at the very start) ───────────────────
        if ($agent === 1 && empty($inputs['brand_voice_snapshot'])) {
            if (class_exists('Costudio_Brand_Voice')) {
                $snapshot = Costudio_Brand_Voice::snapshot_for_production($production->site_key);
                if ($snapshot) {
                    $inputs['brand_voice_snapshot'] = $snapshot;
                    $production->set_inputs($inputs);
                }
            }
        }

        $article_type = Costudio_Article_Types::normalize($inputs['article_type'] ?? '');

        if ($agent === 1) {
            $this->queue_production_started_once($production_id, $production, $inputs, $article_type);
        }

        // Se o agente já foi concluído (coluna preenchida), pula para o próximo.
        $col = $production->map_step_to_column($agent);
        if ($agent <= $max_agents && $col && ! empty($production->$col)) {
            return $agent + 1;
        }

        // Final Publishing Step (Agent Max + 1)
        // Chunked publishing: publish_article() returns TRUE while images are
        // still pending — return the SAME step so the runner re-dispatches it
        // (one background request per image). FALSE => fully published/done.
        if ($agent > $max_agents) {
			if (class_exists('Costudio_Publication_Jobs') && Costudio_Publication_Jobs::enabled()) {
				$row = Costudio_Publication_Jobs::ensure_queued($production_id, 'pipeline_boundary');
				if ($row) {
					Costudio_Publication_Jobs::ensure_progress($production_id, 'pipeline_boundary');
				}
				return $agent;
			}
            return self::execute_publication_job($production_id) ? $agent : null;
        }

        $start_ms = (int) (microtime(true) * 1000);

        Costudio_Logger::info(
            sprintf('Agent %02d started', $agent),
            ['provider' => $this->provider],
            $production_id,
            $agent
        );

        // ── Tipo Notícia: Ignorar Agente 01 ─────────────────────────────────────
        if ($agent === 1 && $article_type === Costudio_Article_Types::NEWS) {
            $output = "## Agent 01 — Discoverer: SKIPPED\n" .
                "Article type: News. Competitive analysis not applicable.\n" .
                "Primary sources provided by the user will be used by Agent 03.";

            Costudio_Production::update_output($production_id, 1, $output);

            if (class_exists('Costudio_Runner')) {
                Costudio_Runner::pipeline_log_append(
                    $production_id,
                    __('Agent 1: Skipped for News article type. Chaining next step…', 'contentosapp-studio'),
                    'info',
                    1
                );
            }

            // Emit Core event so other modules can react.
            $events = costudio_events();
            if (is_object($events) && method_exists($events, 'emit')) {
                $events->emit('costudio.agent_completed', [
                    'agent'         => 1,
                    'production_id' => $production_id,
                ]);
            }

            return 2; // Jump to Agent 02
        }

        // ── Tipo Atualização: pipeline mínimo (somente Agente 04) ────────────────
        // Update is a surgical edit, not a generation. Every agent EXCEPT the
        // Writer (Agent 04) is bypassed regardless of pipeline version:
        // - Discoverer/Strategist/Researcher add nothing when the source article is the input.
        // - Editorial Reviewer, Visual Designer and Social must NOT touch the
        //   live post — the PUT preserves Rank Math meta and social schedules.
        // In v6 pipelines agent 7 does not exist; in v7 it is Social Media.
        // Either way, the only agent that should run is #4.
        if ($article_type === Costudio_Article_Types::UPDATE && $agent !== 4) {
            $labels = [
                1 => 'Discoverer',
                2 => 'Strategist',
                3 => 'Researcher',
                5 => 'Editorial Reviewer',
                6 => 'Visual Designer',
                7 => 'Social Media',
            ];
            $label  = $labels[$agent] ?? 'Agent';
            $output = sprintf(
                "## Agent %02d — %s: SKIPPED\nArticle type: Update. This agent is bypassed in the Update flow — only the Editor (Agent 04) runs against the original article.",
                $agent,
                $label
            );

            Costudio_Production::update_output($production_id, $agent, $output);

            if (class_exists('Costudio_Runner')) {
                Costudio_Runner::pipeline_log_append(
                    $production_id,
                    /* translators: %1$d: agent number */
                    sprintf(
                        /* translators: %1$d: agent number. */
                        __('Agent %1$d: Skipped for Update article type. Chaining next step…', 'contentosapp-studio'),
                        $agent
                    ),
                    'info',
                    $agent
                );
            }

            $events = costudio_events();
            if (is_object($events) && method_exists($events, 'emit')) {
                $events->emit('costudio.agent_completed', [
                    'agent'         => $agent,
                    'production_id' => $production_id,
                ]);
            }

            return $agent + 1;
        }

        // The production snapshot is authoritative. The global setting is used
        // exactly once for legacy productions created before per-production
        // language selection existed, then persisted so retries and later agents
        // cannot switch language when Settings changes.
        $default_content_language = Costudio_Admin::get_default_content_language();
        $lang = (string) ($inputs['lang_code'] ?? '');
        if (! Costudio_Admin::is_supported_content_language($lang)) {
            $lang = $default_content_language;
        }
        $lang_name = Costudio_Admin::get_content_language_name($lang);
        if (($inputs['lang_code'] ?? null) !== $lang || ($inputs['lang_name'] ?? null) !== $lang_name) {
            $inputs['lang_code'] = $lang;
            $inputs['lang_name'] = $lang_name;
            $production->set_inputs($inputs);
            self::pipeline_log('info', 'pipeline.language_legacy_snapshot', array(
                'production_id' => $production_id,
                'lang_code'     => $lang,
            ));
        }
        $is_language_override = $lang !== $default_content_language;

        // ── BYOK prompt freshness (once per production) ─────────────────────
        // In BYOK the pipeline executes the locally cached prompt
        // (costudio_agent_NN, read below). A canonical prompt edited on the
        // ContentOS server would otherwise only reach this install via the
        // background cron — which can lag, sit in cooldown, or be vault-locked
        // — so a run started right after an edit could use a stale prompt.
        // Do one lightweight version check at the start of the run so every
        // BYOK production uses the current prompts. Server unreachable = silent
        // no-op (local cache is used); guarded by a per-production transient so
        // it fires at most once per production, not once per agent.
        if ( ! $is_language_override
            && ! ( $this->client instanceof Costudio_ContentOS_Adapter )
            && class_exists('Costudio_Agent_Seeder') ) {
            $prompt_sync_guard = 'costudio_prompts_run_checked_' . $production_id;
            if ( ! get_transient($prompt_sync_guard) ) {
                set_transient($prompt_sync_guard, 1, HOUR_IN_SECONDS);
                Costudio_Agent_Seeder::sync_if_newer_for_run($lang);
            }
        }

        // ── Site colour (must be resolved BEFORE the is_secure_mode branch) ──
        // map_inputs_for_secure_execution() reads $inputs['site_color'] to fill
        // [NICHE_COLOR] / [NICHE_COLOR_SOFT] for both flows. Setting it only on
        // the local-execution path (as the previous version did) made the
        // remote agents fall back to the niche default (#3b82f6 blue), so the
        // TLDR and any colour-bound prompt directives ignored the site's
        // configured "Cor do tema" entirely — a bug observed in production
        // where output_04 shipped with the default blue regardless of the
        // site's real colour.
        $site_color = '';
        if (! empty($production->site_key) && function_exists('costudio_sites')) {
            $repo = costudio_sites();
            if ($repo) {
                $site_cfg = $repo->get($production->site_key);
                if (! empty($site_cfg['color'])) {
                    $site_color = (string) $site_cfg['color'];
                }
            }
        }
        $inputs['site_color'] = $site_color;

        // Load the local prompt cache / Studio override if saved.
        $prompt_template     = costudio_get_option(sprintf('costudio_agent_%02d', $agent), '');
        $cached_prompt_locale = (string) costudio_get_option('costudio_prompts_locale', '');

        // ── Secure Mode Detection (IP Protection) ───────────────────────────
        // ContentOS Auto must execute the canonical prompt remotely by default.
        // The local costudio_agent_NN options are a cache for admin display /
        // BYOK fallback, and can become stale after prompt refactors. Treat them
        // as executable only when Studio explicitly saved custom agents.
        $is_contentos          = ($this->client instanceof Costudio_ContentOS_Adapter);
        $local_prompt_locked   = empty($prompt_template) || strpos($prompt_template, '[Conteúdo bloqueado') !== false;
        $prompts_customized    = (string) costudio_get_option('costudio_prompts_customized', '') === '1';
        $custom_agents_allowed = true;
        $local_prompt_invalid  = $this->local_prompt_looks_crosswired($prompt_template, $agent);

        // Prompts are no longer user-editable — the canonical prompt is server-owned
        // IP and the local costudio_agent_NN options are a read-only cache. ContentOS
        // Auto therefore ALWAYS runs in Secure Mode: the prompt stays on the server and
        // per-agent Adjustments are sent as gated fields (generate_via_agent). BYOK
        // keeps executing locally with the cached/fetched prompt + local adjustments.
        $use_local_prompt = false;
        $is_secure_mode   = $is_contentos;

        if ($is_contentos && $local_prompt_invalid) {
            self::pipeline_log('warning', 'pipeline.local_prompt_ignored', array(
                'production_id'        => $production_id,
                'agent'                => $agent,
                'reason'               => 'agent_mismatch_or_reviewer_schema',
                'local_prompt_chars'   => strlen((string) $prompt_template),
                'prompts_customized'   => $prompts_customized,
                'custom_agents_allowed' => $custom_agents_allowed,
                'head_180'             => mb_substr((string) $prompt_template, 0, 180),
            ));
        }

        // A durable attempt remains authoritative even if Settings changed
        // provider/mode while its CLI worker was running. Read it before any
        // new search or provider preparation so re-entry is side-effect free.
        $durable_existing_output = null;
		if ('' === $this->durable_worker_job_id && class_exists('Costudio_BYOK_Gateway')) {
            $durable_existing_output = Costudio_BYOK_Gateway::read_existing($production_id, $agent);
        }

        // ── Web search + citation directives (precomputed for both modes) ───
        // Run grounded search BEFORE the is_secure_mode branch so that
        // ContentOS Cloud productions also get sources_json persisted and
        // {{ref:N}} citations resolved by the renderer at publish time.
        // The /ai/agent/generate endpoint otherwise runs without grounding
        // (cap-jobs.php passes with_search=false for is_secure_agent).
        $do_search = in_array($agent, self::SEARCH_AGENTS, true)
            && (bool) get_option('costudio_enable_web_search', true);

        $search_payload = null !== $durable_existing_output
            ? ['md' => '', 'has_sources' => ! empty($production->sources_json), 'grounded_count' => 0]
            : $this->prepare_search_payload($agent, $inputs, $production_id, $do_search);
        $citation_directives = null !== $durable_existing_output
            ? ''
            : $this->build_citation_directives($agent, $production_id);

        // Computado aqui para ser compartilhado entre Secure Mode e BYOK — evita
        // chamar build_type_context() duas vezes e garante comportamento idêntico.
        $type_context         = $this->build_type_context($inputs);
        $local_execution_done = null !== $durable_existing_output;
        if ($local_execution_done) {
            $output = $durable_existing_output;
        }

        if ($is_secure_mode && ! $local_execution_done) {
            // In Secure Mode, we don't interpolate locally. We send raw inputs
            // to the server where the secret prompt is combined and executed.
            $context = $this->build_context($agent, $production, $inputs, (int) ($search_payload['grounded_count'] ?? 0));

            // ── Brand Voice Injection (Secure Mode — Agents 2, 4, 5 & 6) ────────
            // Mirrors the BYOK prepend at the end of this method.
            // Injected BEFORE type_context so that a subsequent array_unshift of
            // type_context pushes it to position [0] and Brand Voice lands at [1],
            // matching the BYOK prompt order: lang → type_context → voice → template.
            if (in_array($agent, [2, 4, 5, 6], true) && class_exists('Costudio_Brand_Voice')) {
                $prod_inputs_snap = json_decode($production->inputs ?? '{}', true);
                $voice_snap       = $prod_inputs_snap['brand_voice_snapshot']
                    ?? Costudio_Brand_Voice::get($production->site_key);
                if ($voice_snap && Costudio_Brand_Voice::is_enabled()) {
                    $voice_mode_sm  = (Costudio_Article_Types::NEWS === $article_type) ? 'vocabulary_only' : 'full';
                    $voice_block_sm = Costudio_Brand_Voice::render_prompt_block($voice_snap, $agent, $lang, $voice_mode_sm);
                    if (! empty($voice_block_sm)) {
                        array_unshift($context, ['role' => 'user', 'content' => $voice_block_sm]);
                    }
                }
            }

            // Inject type context as the first context message so the remote model
            // reads article-type constraints before conversation history and search.
            // Mirrors exactly what the BYOK path prepends to the local prompt.
            if (! empty($type_context)) {
                array_unshift($context, [
                    'role'    => 'user',
                    'content' => "## MANDATORY ARTICLE TYPE CONTEXT\n\n" . $type_context,
                ]);
            }

            // ── Output Language Lock (Secure Mode) ──────────────────────────────
            // CRITICAL: the BYOK path prepends an explicit language instruction
            // (see below), but the secure path historically relied only on the
            // server interpolating [LANGUAGE] into the canonical template. That
            // token was out-voted by the Portuguese brand-voice samples + site
            // context prepended above, flipping Auto productions to the wrong
            // language. Unshift LAST so the lock lands at position [0] — the most
            // authoritative slot — matching the documented order: lang →
            // type_context → voice → template.
            array_unshift($context, [
                'role'    => 'user',
                'content' => $this->language_lock_block($lang_name),
            ]);

            // Inject the grounded search results so the remote agent sees the
            // same INDEXED SOURCES the renderer will resolve later.
            if ($search_payload['md'] !== '') {
                $context[] = [
                    'role'    => 'user',
                    'content' => "=== RESULTADOS DE BUSCA NA WEB ===\n\n" . $search_payload['md'],
                ];
            }

            // Inject citation directives (SYSTEM OVERRIDE) so the remote
            // agent obeys the same Cenário A/B rules as the local pipeline.
            // Skip for UPDATE + Agent 04: the UPDATE override already says
            // "DO NOT cite new sources", so citation rules are irrelevant and
            // only add contradictory noise to an already large context.
            $skip_citations_update = $agent === 4
                && ('update' === ($inputs['article_type'] ?? ''));
            if ($citation_directives !== '' && ! $skip_citations_update) {
                $context[] = [
                    'role'    => 'user',
                    'content' => "=== INSTRUÇÕES DE CITAÇÃO (SYSTEM OVERRIDE) ===\n\n" . $citation_directives,
                ];
            }

            self::pipeline_log('info', 'pipeline.secure_inject', array(
                'production_id'         => $production_id,
                'agent'                 => $agent,
                'search_chars'          => strlen($search_payload['md']),
                'override_chars'        => strlen($citation_directives),
                'type_context_chars'    => strlen($type_context),
                'has_sources'           => $search_payload['has_sources'],
                'grounding_disabled'    => $this->web_search_effectively_disabled(),
                'context_messages'      => count($context),
                'site_color'            => $inputs['site_color'] ?? '',
                'local_prompt_chars'    => strlen((string) $prompt_template),
                'prompts_customized'    => $prompts_customized,
                'custom_agents_allowed' => $custom_agents_allowed,
            ));

            // Per-agent Adjustments (niche / extra / temperature) are NOT applied
            // here — they are sent as gated fields by generate_via_agent and applied
            // server-side for Pro/Studio only. This keeps Adjustments out of the Auto
            // payload for Starter (BYOK still applies them locally, further below).

            // Map inputs for server-side interpolation.
            $secure_inputs = $this->map_inputs_for_secure_execution($inputs);

            try {
                // CRITICAL: Ensure remote quota tracking sees the production_id.
                // Otherwise ContentOS API will treat each agent call as a standalone
                // request and debit the monthly quota multiple times (once per agent).
                if (method_exists($this->client, 'set_production_id')) {
                    $this->client->set_production_id($production_id);
                }
                // When web search is off, tell the server not to auto-ground —
                // keeps the article free of external sources, as intended.
                if (method_exists($this->client, 'set_grounding_disabled')) {
                    $this->client->set_grounding_disabled($this->web_search_effectively_disabled());
                }
                $this->apply_thinking_budget($agent);

                // Final check before long remote AI call.
                $fresh = Costudio_Production::get_by_id($production_id);
                if ( $fresh && in_array( $fresh->status, [ 'paused', 'cancelled' ], true ) ) {
                    return null;
                }

                $output = $this->client->generate_via_agent($agent, $secure_inputs, $context);

                // Record metrics for Secure Mode (ContentOS Cloud)
                if ($production_id > 0) {
                    $latency_ms = (int) (microtime(true) * 1000) - $start_ms;
                    $timing     = $this->resolve_usage_timing( $production_id, $agent, $start_ms );
                    Costudio_Usage::record([
                        'production_id' => $production_id,
                        'agent'         => $agent,
                        'provider'      => $this->provider,
                        'model'         => 'ContentOS Auto',
                        'input_tokens'  => 0,
                        'output_tokens' => 0,
                        'latency_ms'    => $timing['elapsed_ms'] ?: $latency_ms,
                        'started_at'    => $timing['started_at'],
                        'grounding'     => $do_search,
                    ]);
                }

                // ── Phase 4 fallback: capture sources from remote response ──
                // If the server ran grounding too (older plugin builds didn't
                // pre-fetch) and we don't yet have local sources, persist its
                // result so the renderer has something to work with.
                if (
                    $agent === 3
                    && empty($search_payload['has_sources'])
                    && method_exists($this->client, 'get_last_sources')
                ) {
                    $remote_sources = $this->client->get_last_sources();
                    if (! empty($remote_sources)) {
                        Costudio_Production::set_sources_json($production_id, $remote_sources);
                        self::pipeline_log('info', 'pipeline.secure_inject.remote_sources_persisted', array(
                            'production_id' => $production_id,
                            'count'         => count($remote_sources),
                        ));
                    }
                }

                // Skip local execution — output already obtained from remote.
                $local_execution_done = true;
            } catch (\Throwable $e) {
                // Chunked remote polling (Costudio_ContentOS_Adapter): the AI job
                // for THIS agent is still processing after one short chunk — it
                // has not failed. Let it propagate untouched to Costudio_Runner,
                // which catches it specifically and re-dispatches this same
                // agent instead of treating it as a pipeline error. Must be
                // checked before any of the fallback/error handling below.
                if ($e instanceof Costudio_Generation_Deferred_Exception) {
                    throw $e;
                }

                // Remote execution failed. Two scenarios:
                //   (1) User has a real local prompt (BYOK transition) → fall back
                //       to local execution.
                //   (2) Secure Mode without local prompt → there is NOTHING to fall
                //       back to. Silently swallowing the error here used to drop the
                //       production into the "missing prompt" branch below and abort
                //       in a way the Runner mistook for "pipeline finished" (it then
                //       overwrote the DB status from 'error' to 'completed', shipping
                //       a zero-words "ghost completed" article). We now log the real
                //       cause and fail loud.
                $err_msg     = $e->getMessage();
                $had_local   = ! $local_prompt_locked && $use_local_prompt;
                $err_context = [
                    'agent'         => $agent,
                    'production_id' => $production_id,
                    'error'         => $err_msg,
                    'had_local'     => $had_local,
                ];

                if ($had_local) {
                    // Scenario (1): real BYOK prompt available → fall through to local execution.
                    Costudio_Logger::warn(
                        sprintf('Secure Mode failed for Agent %d, falling back to local prompt: %s', $agent, $err_msg),
                        $err_context,
                        $production_id,
                        $agent
                    );
                } else {
                    // Scenario (2): no real local prompt → fail the production and stop.
                    // We DO NOT continue into the BYOK path with an empty/teaser
                    // template — that would either submit a meaningless prompt or
                    // hit the "missing prompt" guard and produce the silent abort
                    // that masqueraded as a successful run.

                    // Managed-service quota / entitlement limits (quota_exceeded,
                    // site_limit_exceeded) are NOT pipeline failures — they are
                    // actionable upgrade states. Persist 'pending_upgrade' instead of
                    // the hard red 'error' set below, so the detail view renders the
                    // friendly upgrade card ("Buy extra credits" / "Switch to BYOK").
                    // The Runner detects 'pending_upgrade' right after run_step()
                    // returns and stops the pipeline cleanly without overwriting it.
                    // Without this, the generic Throwable handling below would mask
                    // the typed exception and force a generic 'error'.
                    if ( $e instanceof Costudio_Upgrade_Required_Exception ) {
                        Costudio_Logger::warn(
                            sprintf( 'Agent %d halted on managed-service limit (%s): %s', $agent, $e->get_error_code(), $err_msg ),
                            $err_context,
                            $production_id,
                            $agent
                        );
                        Costudio_Production::mark_upgrade_required(
                            $production_id,
                            $e->get_error_code(),
                            $e->getMessage(),
                            $e->get_upgrade_url(),
                            $agent
                        );
                        return null;
                    }

                    Costudio_Logger::error(
                        sprintf('Secure Mode failed for Agent %d (no local fallback): %s', $agent, $err_msg),
                        $err_context,
                        $production_id,
                        $agent
                    );

                    // User-friendly message that surfaces the real cause without
                    // exposing internal exception traces.
                    $friendly = self::build_secure_mode_failure_message($err_msg, $agent);
                    Costudio_Production::set_error($production_id, $friendly, $agent);
                    return null;
                }
            }
        }

        if (! $local_execution_done) {
        // ── BYOK + teaser: buscar prompt completo para execução ─────────────
        // Instalações antigas podem ter apenas um teaser (280 chars) do seeder para
        // exibição no painel. Quando o modo de execução é BYOK (não ContentOS Auto),
        // precisamos do prompt real para gerar o artigo. Buscamos uma cópia de
        // execução sem persistir no DB — o teaser de display permanece intacto.
        if ( ! $is_contentos && ($local_prompt_locked || $is_language_override) ) {
            $fetched = $this->fetch_execution_prompt( $agent, $lang, $production_id );
            if ( $fetched !== '' ) {
                $prompt_template = $fetched;
            } elseif (
                strpos( (string) $prompt_template, '[Conteúdo bloqueado' ) !== false
                || ($is_language_override && $cached_prompt_locale !== $lang)
            ) {
                // Never execute a prompt cached for another language. An exact
                // ephemeral fetch may fall back only to a full cache whose locale
                // metadata matches this production snapshot.
                $msg = sprintf(
                    /* translators: %d: agent number */
                    __( 'Agent %d: could not retrieve the full prompt for BYOK execution. Check ContentOS API connectivity and try syncing prompts again.', 'contentosapp-studio' ),
                    $agent
                );
                Costudio_Logger::error( $msg, [ 'agent' => $agent ], $production_id, $agent );
                Costudio_Production::set_error( $production_id, $msg, $agent );
                return null;
            }
            // Se $prompt_template estava vazio e o fetch falhou, cai nos fallbacks abaixo.
        }

		if (empty($prompt_template)) {
			$prompt_template = Costudio_Admin::get_agent_default_prompt_text($agent, $lang);
		}

		if (empty($prompt_template) && ! $is_secure_mode) {
			$prompt_template = $this->attempt_prompt_resync($agent, $lang, $production_id);
		}

		if (empty($prompt_template)) {
			// run_step() must return ?int. Treat missing prompt as a fatal production error.
			/* translators: %d: agent number */
			$msg = sprintf(
				/* translators: %d: agent number. */
				__('Agent %d prompt not configured.', 'contentosapp-studio'),
				$agent
			);
            Costudio_Logger::error($msg, ['agent' => $agent], $production_id, $agent);
            Costudio_Production::set_error($production_id, $msg, $agent);
			return null;
		}

        // Studio: per-agent niche override — applied before interpolation so [NICHE] is substituted correctly.
        $agent_niche = costudio_get_option(sprintf('costudio_agent_%02d_niche', $agent), '');
        if (! empty($agent_niche)) {
            $inputs['nicho'] = sanitize_text_field($agent_niche);
        }

        // (site_color is resolved before the is_secure_mode branch above so
        // both Secure Mode and BYOK execution paths get consistent values.)

        $prompt = $this->interpolate($prompt_template, $inputs);

        // Studio: per-agent extra instructions — appended after the full prompt.
        // Extras go through interpolate() so [KEYWORD], [NICHE] etc. resolve correctly,
        // matching the same substitution applied to the main prompt template above.
        $agent_extra = costudio_get_option(sprintf('costudio_agent_%02d_extra', $agent), '');
        if (! empty($agent_extra)) {
            $agent_extra_interpolated = $this->interpolate(wp_strip_all_tags($agent_extra), $inputs);
            $prompt .= "\n\n---\n## " . __('ADDITIONAL INSTRUCTIONS', 'contentosapp-studio') . "\n" . $agent_extra_interpolated;
        }

        // ── Brand Voice Injection (BYOK — Agents 2, 4, 5 & 6) ─────────────
        // A2 (Strategist) added so editorial angle, title, and tone brief are
        // brand-aware from the start — prevents A4 from fighting A2's unstylised brief.
        // A5 (Reviewer) uses the block for voice_alignment evaluation (see render_prompt_block).
        // News articles receive vocabulary_only mode: tone sliders are skipped so
        // journalistic flow can emerge from sources without brand-imposed tone.
        if (in_array($agent, [2, 4, 5, 6], true) && class_exists('Costudio_Brand_Voice')) {
            $prod_inputs = json_decode($production->inputs ?? '{}', true);
            $voice       = $prod_inputs['brand_voice_snapshot'] ?? Costudio_Brand_Voice::get($production->site_key);

            if ($voice && Costudio_Brand_Voice::is_enabled()) {
                $voice_mode  = (Costudio_Article_Types::NEWS === $article_type) ? 'vocabulary_only' : 'full';
                $voice_block = Costudio_Brand_Voice::render_prompt_block($voice, $agent, $lang, $voice_mode);
                if (! empty($voice_block)) {
                    $prompt = $voice_block . "\n---\n" . $prompt;
                }
            }
        }

        // $type_context already computed before the is_secure_mode branch (shared with Secure Mode path).
        // Authoritative output-language lock — shared with the secure path so both
        // execution modes enforce the content language identically (the missing
        // lock on the secure path is what let Auto productions flip to the site's
        // language despite an English content setting).
        $lang_instruction = $this->language_lock_block($lang_name) . "\n---\n";

        // Correct order: lang → type_context → template.
        // Type context must appear BEFORE the template (and before ## OUTPUT anchors)
        // so the model receives critical type-specific instructions with full attention.
        if (! empty($type_context)) {
            $prompt = $lang_instruction
                . "## " . __('MANDATORY ARTICLE TYPE CONTEXT', 'contentosapp-studio') . "\n"
                . $type_context
                . "\n---\n"
                . $prompt;
        } else {
            $prompt = $lang_instruction . $prompt;
        }

        $context = $this->build_context($agent, $production, $inputs, (int) ($search_payload['grounded_count'] ?? 0));

        if (method_exists($this->client, 'set_production_id')) {
            $this->client->set_production_id($production_id);
        }
        $this->apply_thinking_budget($agent);
        $this->apply_temperature($agent);
        } // end if (! $local_execution_done) — BYOK setup

        try {
            if (! $local_execution_done) {
            // Search payload was precomputed before the is_secure_mode branch
            // so both flows share the same grounding + sources_json behaviour.
            if ($search_payload['md'] !== '') {
                $prompt = "=== RESULTADOS DE BUSCA NA WEB ===\n\n"
                    . $search_payload['md']
                    . "\n\n=== INSTRUÇÃO ===\n\n"
                    . $prompt;
            }

            // Citation directives (SYSTEM OVERRIDE) likewise precomputed.
            // Skip for UPDATE + Agent 04: citation rules contradict the UPDATE
            // override and add unnecessary noise to the context.
            $skip_citations_update_byok = $agent === 4
                && ('update' === ($inputs['article_type'] ?? ''));
            if ($citation_directives !== '' && ! $skip_citations_update_byok) {
                $prompt .= "\n\n---\n" . $citation_directives;
            }

            if (in_array($agent, self::SEARCH_AGENTS, true)) {
                self::pipeline_log('info', 'agent.prompt.preview', array(
                    'production_id'       => $production_id,
                    'agent'               => $agent,
                    'do_search'           => $do_search,
                    'prompt_chars'        => strlen($prompt),
                    'has_indexed_sources' => strpos($prompt, '## INDEXED SOURCES — cite with {{ref:N}} placeholders') !== false,
                    'has_search_results'  => strpos($prompt, '=== RESULTADOS DE BUSCA') !== false,
                    'has_directives'      => $citation_directives !== '',
                    'prompt_head_300'     => mb_substr($prompt, 0, 300),
                ));
            }

            // Final check before long local AI call.
            $fresh = Costudio_Production::get_by_id($production_id);
			if (
				$fresh
				&& (
					'cancelled' === $fresh->status
					|| ( 'paused' === $fresh->status && '' === $this->durable_worker_job_id )
				)
			) {
                return null;
            }

            // Search (when applicable) is now performed in the Pipeline above and
            // prepended to the prompt — adapters never receive $with_search=true.
            $output = $this->guarded_call(false, $prompt, $context, $production_id, $agent, $do_search);
        } // end if (! $local_execution_done)

            // ── Output sanity guard ──────────────────────────────────────────
            // Empty / suspiciously short payloads are the canonical symptom of
            // a silent provider failure (HTTP 200 with empty body, truncated
            // streaming response, content filter strip, etc.). Persisting them
            // produced the "ghost completed" production we observed in 19:48.
            // Real outputs are always >> 100 chars (Agent 1 ≈ 5k, Agent 4 ≈ 8k+).
            // Threshold is filterable so unusual setups can override.
            $output_text = is_string($output) ? trim($output) : '';
            $min_chars   = (int) apply_filters('costudio_agent_output_min_chars', 100, $agent, $production_id);
            if (mb_strlen($output_text) < $min_chars) {
                Costudio_Logger::error(
                    sprintf('Agent %d returned empty/short output (%d chars, min %d)', $agent, mb_strlen($output_text), $min_chars),
                    [
                        'agent'         => $agent,
                        'production_id' => $production_id,
                        'output_len'    => mb_strlen($output_text),
                        'min_chars'     => $min_chars,
                        'preview'       => mb_substr($output_text, 0, 200),
                    ],
                    $production_id,
                    $agent
                );
                throw new Exception(sprintf(
                    /* translators: 1: agent number, 2: actual chars, 3: min chars */
                    __('Agente %1$d retornou conteúdo vazio ou suspeitamente curto (%2$d caracteres, mínimo %3$d). Use "Reprocessar daqui" para tentar novamente.', 'contentosapp-studio'),
                    $agent,
                    mb_strlen($output_text),
                    $min_chars
                ));
            }
            $output = $output_text;

            if ($agent === 4) {
                $this->assert_valid_writer_output($output, $article_type, $inputs, $production_id);
            }

            // ── Source Extraction (BYOK-only, legacy support) ────────────────
            // When ContentOS native search grounding is ON, we persist structured
            // sources directly from `/ai/search` (see pipeline_search()) and we
            // MUST NOT scrape URLs from agent prose — the LLM can hallucinate
            // plausible-but-broken paths, polluting the `{{ref:N}}` pool.
            //
            // Therefore: only scrape markdown URLs when native grounding is OFF.
            // Also skip Agent 01 entirely: competitor URLs are not citations.
            $cts       = function_exists('costudio_settings') ? costudio_settings() : null;
            $ai_mode   = $cts && method_exists($cts, 'resolve_ai_mode')
                ? (string) $cts->resolve_ai_mode()
                : ( $cts ? (string) $cts->get('ai_mode', 'contentos') : 'contentos' );
            $native_on = $cts && method_exists($cts, 'contentos_native_search_enabled')
                ? (bool) $cts->contentos_native_search_enabled($ai_mode)
                : ( 'byok' !== $ai_mode && (bool) get_option('costudio_enable_web_search', true) && ( $cts ? (bool) $cts->get('search_grounding', true) : true ) );
            // Skip the legacy prose scraper when STRUCTURED grounding already
            // populated sources_json (Exa/Serper/Tavily). It would otherwise re-add
            // agent-prose URLs WITHOUT the user-external dedupe — polluting the
            // {{ref:N}} pool and re-introducing URLs we deliberately deduped against
            // the user's links. Mirrors the native rule ("never scrape agent prose
            // when grounding is structured"). Gemini (raw context, no structured
            // sources) still uses the scraper until its structured follow-up lands.
            // Structured provider (Exa/Serper/Tavily) → always skip the scraper.
            // PLUS a dynamic signal for providers whose structured-ness is
            // conditional (Gemini grounding only fires with billing): skip when the
            // structured grounding actually persisted the pool THIS run.
            $structured_provider = (bool) get_option('costudio_enable_web_search', true)
                && class_exists('Costudio_Search_Engine')
                && Costudio_Search_Engine::provider_is_structured(
                    $cts ? (string) $cts->get('search_api_provider', '') : ''
                );
            $grounded_persisted   = (bool) get_transient('costudio_grounded_persisted_' . $production_id);
            $structured_grounding = $structured_provider || $grounded_persisted;
            if ($ai_mode === 'byok' && ! $native_on && ! $structured_grounding && in_array($agent, [2, 3], true)) {
                $this->extract_and_merge_sources($production_id, $output);
            }

            // ── Word-count control for Agent 04 ──────────────────────────────
            $warning = null;
            if ($agent === 4) {
                // ── Image-markers self-heal (deterministic, no LLM retry) ───
                // The Writer occasionally forgets to insert [IMG:imagemN.webp | Alt]
                // markers (Agent 05 then has nothing to work with, and the
                // "placeholder" image mode publishes an article with zero
                // visual cues). We self-heal locally based on the article's
                // own structure: pick the H2 sections that need a visual the
                // most, synthesise contextual Alt text from the heading, and
                // splice the markers in. Zero extra LLM calls, zero added
                // latency, fully respects the user's image_qty setting.
                $cts_imgs    = function_exists('costudio_settings') ? costudio_settings() : null;
                // Use per-production snapshot when available (set at creation time).
                $image_mode  = $inputs['costudio_image_mode']
                    ?? ($cts_imgs ? (string) $cts_imgs->get('costudio_image_mode', 'generate') : 'generate');
                $image_qty   = isset($inputs['costudio_image_quantity'])
                    ? intval($inputs['costudio_image_quantity'])
                    : ($cts_imgs ? intval($cts_imgs->get('costudio_image_quantity', 2)) : 2);
                $image_ai_mode = (string) ($inputs['execution_ai_mode'] ?? $inputs['ai_mode'] ?? ($cts_imgs ? $cts_imgs->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));

                // Inline slots = total images minus cover (imagem1). Must match
                // [TOTAL_IMAGES_INLINE] and the publisher — never force a minimum
                // of 2 markers when the user only configured 2 images total (1 inline).
                $desired_inline = max(0, $image_qty - 1);
                // Update outputs [SECTION N] protocol text, not a final article —
                // injecting [IMG:...] markers here would corrupt the protocol and
                // trigger spurious image generation on surgical-edit content.
                if ($article_type !== Costudio_Article_Types::UPDATE) {
                    // In placeholder mode the Writer places markers autonomously
                    // ([TOTAL_IMAGES_INLINE] = 'auto') — never force a fixed count.
                    if ($image_mode !== 'none' && $image_mode !== 'placeholder' && $desired_inline >= 1) {
                        $output = $this->ensure_inline_image_markers(
                            $output,
                            $desired_inline,
                            (string) ($inputs['keyword'] ?? ''),
                            $production_id
                        );
                    }

                    $output = $this->normalize_img_markers_to_seo_filenames(
                        $output,
                        (string) ($inputs['keyword'] ?? '')
                    );
                }

                // Update is a surgical edit — the word count is intentionally
                // low (only edited sections, not the full preserved article).
                // Skip the wc check entirely to avoid a false warning banner.
                if ($article_type !== Costudio_Article_Types::UPDATE) :

                $extensao  = $inputs['extensao'] ?? '2000-2600';
                $limits    = $this->parse_extensao($extensao);
                $real_max  = $limits['max'];
                $real_min  = $limits['min'];
                $real_alvo = $limits['alvo'];
                $wc        = $this->count_words($output);

                self::pipeline_log('info', 'agent4.wc.initial', array(
                    'production_id' => $production_id,
                    'wc'            => $wc,
                    'min'           => $real_min,
                    'max'           => $real_max,
                    'target'        => $real_alvo,
                    'extensao'      => $extensao,
                    'has_faq'       => preg_match('/##\s+(FAQ|Perguntas Frequentes|Frequently Asked|Preguntas Frecuentes)/iu', $output) === 1,
                ));

                // ── Word-count policy: observe-only ─────────────────────────
                // Calibration ([LENGTH_TARGET], [LENGTH_MAX], [MAX_SECTIONS]) is
                // injected upstream as guidance to the LLM. We deliberately do
                // NOT rewrite the article when the count drifts outside the
                // requested range — that would prioritise rule rigidity over
                // content quality and creativity. The user prefers the natural
                // output. Limits become advisory warnings only: persisted via
                // Costudio_Production::set_wc_warning() so the admin detail
                // page surfaces a yellow [WC_WARNING] banner; the pipeline
                // continues uninterrupted.
                $warning = '';
                if ($wc < $real_min) {
                    $warning = sprintf(
                        /* translators: 1: actual word count, 2: minimum suggested */
                        __('ℹ️ O texto editorial foi gerado com %1$d palavras, abaixo do mínimo sugerido de %2$d. Conteúdo mantido na íntegra para preservar a criatividade do modelo.', 'contentosapp-studio'),
                        $wc,
                        $real_min
                    );
                } elseif ($wc > $real_max) {
                    $warning = sprintf(
                        /* translators: 1: actual word count, 2: max suggested */
                        __('ℹ️ O texto editorial foi gerado com %1$d palavras, ultrapassando o limite superior sugerido de %2$d palavras. O conteúdo foi mantido na íntegra para garantir a máxima qualidade e profundidade do tema.', 'contentosapp-studio'),
                        $wc,
                        $real_max
                    );
                }

                if ($warning !== '') {
                    Costudio_Production::set_wc_warning($production_id, $warning);

                    if (class_exists('Costudio_Runner')) {
                        Costudio_Runner::pipeline_log_append(
                            $production_id,
                            $warning,
                            'warn',
                            4
                        );
                    }
                }

                $final_wc = $this->count_words($output);
                self::pipeline_log('info', 'agent4.wc.final', array(
                    'production_id' => $production_id,
                    'wc'            => $final_wc,
                    'min'           => $real_min,
                    'max'           => $real_max,
                    'within_range'  => $final_wc >= $real_min && $final_wc <= $real_max,
                    'has_faq'       => preg_match('/##\s+(FAQ|Perguntas Frequentes|Frequently Asked|Preguntas Frecuentes)/iu', $output) === 1,
                ));

                endif; // $article_type !== UPDATE
            }
            // ─────────────────────────────────────────────────────────────────

            if ( 6 === $agent && Costudio_Article_Types::UPDATE !== $article_type ) {
                $output = $this->finalize_visual_plan_output( $output, $production, $production_id );
            }

            Costudio_Production::update_output($production_id, $agent, $output);

			// The durable attempt is marked applied by Costudio_Runner only after
			// current_agent advances. Persisting output alone is not the local
			// orchestration handoff and must not silence the readback watchdog.

            // Close the timing boundary only after all deterministic agent
            // post-processing has completed and the final output is durable.
            $final_timing = Costudio_Production::get_agent_attempt_timing( $production_id, $agent );
            if ( ! empty( $final_timing['started_at'] ) && (int) $final_timing['elapsed_ms'] > 0 ) {
                Costudio_Usage::finalize_agent_timing(
                    $production_id,
                    $agent,
                    (string) $final_timing['started_at'],
                    (int) $final_timing['elapsed_ms']
                );
            }

            // ── Telemetry: Agent Completed ───────────────────────────────────
            if (class_exists('Costudio_Telemetry')) {
                $usage = Costudio_Usage::get_agent_usage($production_id, $agent);
                if ($usage) {
                    $production_uid = Costudio_Production::tracking_uid_for($production_id);
                    Costudio_Telemetry::queue('agent.completed', [
                        'production_id'  => $production_id,
                        'production_uid' => $production_uid,
                        'agent_num'      => $agent,
                        'type'           => $article_type,
                        'locale'         => function_exists('costudio_resolve_ui_locale_from_options') ? costudio_resolve_ui_locale_from_options() : 'pt_BR',
                        'category'       => 'text',
                        'duration_ms'    => (int) $usage['latency_ms'],
                        'input_tokens'   => (int) $usage['input_tokens'],
                        'output_tokens'  => (int) $usage['output_tokens'],
                        'cost_usd'       => (float) $usage['cost_usd'],
                        'provider'       => $usage['provider'],
                        'model'          => $usage['model'],
                        'grounding'      => $do_search,
                    ]);
                }
            }

            // Emit Core event.
            $events = costudio_events();
            if (is_object($events) && method_exists($events, 'emit')) {
                $events->emit('costudio.agent_completed', [
                    'agent'         => $agent,
                    'production_id' => $production_id,
                ]);
            }

            // Return next step (Agent 1-7 → Next, Agent 8 → Publish step).
            $next_step = $agent + 1;

            // ── Editorial Loop Logic (Agent 05 → 04) ─────────────────────────
            if ($agent === 5 && $version === 7) {
                // Try to find JSON in the output (in case of preambles)
                $json_start = strpos($output, '{');
                $json_end   = strrpos($output, '}');
                $verdict    = null;

                if ($json_start !== false && $json_end !== false) {
                    $json_str = substr($output, $json_start, $json_end - $json_start + 1);
                    $verdict  = json_decode($json_str, true);
                }

                $cycles = isset($inputs['revision_cycles']) ? (int) $inputs['revision_cycles'] : 0;

                // Rule: If rejected AND we haven't reached the hard limit (1 revision)
                if (is_array($verdict) && isset($verdict['approved']) && ! $verdict['approved'] && $cycles < 1) {

                    // `regenerate_recommended` (A05 schema) signals an irrecoverable
                    // draft — Writer regenerates from scratch instead of patching.
                    $regenerate = ! empty($verdict['regenerate_recommended']);

                    if ($regenerate) {
                        // Strip prior revision context so Writer starts fresh.
                        unset($inputs['last_reviewer_feedback'], $inputs['last_draft']);
                        $log_summary  = sprintf('Editorial Reviewer flagged draft as irrecoverable. Regenerating from scratch (cycle %d/1).', $cycles + 1);
                        /* translators: %d: current revision cycle number */
                        $user_message = sprintf(
                            /* translators: %d: review cycle number. */
                            __('Revisor Editorial sinalizou artigo irrecuperável. Regenerando do zero (ciclo %d/1)...', 'contentosapp-studio'),
                            $cycles + 1
                        );
                        $user_level   = 'warn';
                    } else {
                        // Standard revision: pass feedback + draft back to Writer.
                        $inputs['last_reviewer_feedback'] = $verdict;
                        $inputs['last_draft']             = $production->output_04;
                        $log_summary  = sprintf('Editorial Reviewer rejected Agent 04. Starting revision cycle %d/1.', $cycles + 1);
                        /* translators: %d: current revision cycle number */
                        $user_message = sprintf(
                            /* translators: %d: review cycle number. */
                            __('Revisor Editorial solicitou ajustes. Iniciando ciclo de revisão %d/1...', 'contentosapp-studio'),
                            $cycles + 1
                        );
                        $user_level   = 'info';
                    }

                    $inputs['revision_cycles'] = $cycles + 1;

                    Costudio_Logger::info(
                        $log_summary,
                        ['verdict' => $verdict, 'regenerate' => $regenerate],
                        $production_id,
                        5
                    );

                    Costudio_Production::update_inputs($production_id, $inputs);

                    // This is an internal revision within the SAME production
                    // session. Clear Writer+ outputs and start a new Writer
                    // attempt, but preserve the production's original started_at
                    // so the main elapsed clock never restarts.
                    Costudio_Production::reset_for_retry($production_id, 4, 'running', false);

                    if (class_exists('Costudio_Runner')) {
                        Costudio_Runner::pipeline_log_append(
                            $production_id,
                            $user_message,
                            $user_level,
                            5
                        );
                    }

                    return 4; // Backtrack to Writer
                }
                
                // If we reach here, it's either approved or we hit the cycle limit.
                // We proceed to Agent 06.
                $next_step = 6;
            }

            return $next_step;
        } catch (\Throwable $e) {
            // Re-throw to be caught by Costudio_Runner::cron_run_background()
            throw $e;
        }
    }

    /**
     * Validate and canonicalize the structured visual plan before the Agent 06
     * output becomes durable. A malformed plan must never abort the article:
     * it is removed and the publisher remains free to use the legacy/fallback
     * path.
     */
    private function finalize_visual_plan_output(string $output, object $production, int $production_id): string
    {
        if ( ! class_exists( 'Costudio_Visual_Plan' ) ) {
            return $output;
        }

        $has_visual_plan_tag = false !== stripos( $output, '<VISUAL_PLAN>' );
        $plan                = Costudio_Visual_Plan::extract( $output );

        if ( null === $plan ) {
            if ( $has_visual_plan_tag ) {
                self::visual_log( 'warning', 'visual.plan_validation_failed', array(
                    'production_id' => $production_id,
                    'error_codes'   => array( 'invalid_or_truncated_json' ),
                    'fallback'      => 'legacy_or_no_images',
                ), $production_id );

                if ( class_exists( 'Costudio_Runner' ) ) {
                    Costudio_Runner::pipeline_log_append(
                        $production_id,
                        __( 'The visual plan was invalid and was safely ignored. The article will continue without blocking publication.', 'contentosapp-studio' ),
                        'warn',
                        6
                    );
                }

                return Costudio_Visual_Plan::strip_block( $output );
            }

            // Backward compatibility: Agent 06 may still return the legacy
            // Markdown/JSON prompt format while prompts roll out gradually.
            return $output;
        }

        $article_output = '';
        if ( method_exists( $production, 'get_agent_output' ) ) {
            $article_output = (string) $production->get_agent_output( 4 );
        } elseif ( isset( $production->output_04 ) ) {
            $article_output = (string) $production->output_04;
        }

        $expected           = Costudio_Visual_Plan::expected_images_from_article( $article_output, true );
        $context_by_filename = $this->visual_context_by_filename( $article_output, $production, $production_id );
        $result             = Costudio_Visual_Plan::validate_and_normalize( $plan, $expected, $context_by_filename );

        if ( ! empty( $result['valid'] ) ) {
            self::visual_log( 'info', 'visual.plan_created', array(
                'production_id' => $production_id,
                'asset_count'   => count( $result['plan']['assets'] ?? array() ),
                'warning_codes' => array_values( array_unique( array_column( $result['warnings'] ?? array(), 'code' ) ) ),
            ), $production_id );

            return Costudio_Visual_Plan::replace_block( $output, $result['plan'] );
        }

        $error_codes = array_values( array_unique( array_filter( array_column( $result['errors'] ?? array(), 'code' ) ) ) );
        self::visual_log( 'warning', 'visual.plan_validation_failed', array(
            'production_id' => $production_id,
            'error_codes'   => $error_codes,
            'rejected_ids'  => $result['repair']['rejected_asset_ids'] ?? array(),
            'fallback'      => 'legacy_or_no_images',
        ), $production_id );

        if ( class_exists( 'Costudio_Runner' ) ) {
            Costudio_Runner::pipeline_log_append(
                $production_id,
                __( 'The visual plan did not pass the quality checks and was safely ignored. The article will continue without blocking publication.', 'contentosapp-studio' ),
                'warn',
                6
            );
        }

        /**
         * Optional, deliberately bounded repair seam. A provider adapter may
         * return one corrected plan (array) or one corrected VISUAL_PLAN block
         * (string). Core never retries or loops here.
         */
        $repair_candidate = apply_filters(
            'costudio_visual_plan_repair_output',
            null,
            $result['repair'] ?? array(),
            array(
                'production_id' => $production_id,
                'agent_id'      => 6,
            )
        );

        $repair_plan = null;
        if ( is_array( $repair_candidate ) ) {
            $repair_plan = $repair_candidate;
        } elseif ( is_string( $repair_candidate ) && '' !== trim( $repair_candidate ) ) {
            $repair_plan = Costudio_Visual_Plan::extract( $repair_candidate );
        }

        if ( is_array( $repair_plan ) ) {
            $repaired = Costudio_Visual_Plan::validate_and_normalize( $repair_plan, $expected, $context_by_filename );
            if ( ! empty( $repaired['valid'] ) ) {
                self::visual_log( 'info', 'visual.plan_repaired', array(
                    'production_id' => $production_id,
                    'asset_count'   => count( $repaired['plan']['assets'] ?? array() ),
                ), $production_id );

                return Costudio_Visual_Plan::replace_block( $output, $repaired['plan'] );
            }
        }

        return Costudio_Visual_Plan::strip_block( $output );
    }

    /**
     * Recomputes the SAME deterministic context packet Agent 06 was given
     * (Costudio_Pipeline_Summaries::build_visual_context_block() is a pure
     * function of article + A01/A03 outputs, so recomputing here is safe and
     * idempotent) and reduces it to a filename-keyed lookup the validator can
     * cross-check the model's editorial_role claim against — specifically
     * whether "evidence-data" was actually backed by a verified fact when the
     * asset was planned, never taken on the model's word alone.
     */
    private function visual_context_by_filename( string $article_output, object $production, int $production_id ): array
    {
        if ( ! class_exists( 'Costudio_Pipeline_Summaries' ) || ! class_exists( 'Costudio_Visual_Plan' ) ) {
            return array();
        }

        $block = Costudio_Pipeline_Summaries::build_visual_context_block(
            $article_output,
            array(),
            (string) ( $production->output_01 ?? '' ),
            (string) ( $production->output_03 ?? '' )
        );
        if ( ! preg_match( '/<VISUAL_CONTEXT>\s*(.*?)\s*<\/VISUAL_CONTEXT>/s', $block, $m ) ) {
            return array();
        }
        $decoded = json_decode( $m[1], true );
        $assets  = is_array( $decoded ) && is_array( $decoded['assets'] ?? null ) ? $decoded['assets'] : array();

        $by_filename = array();
        foreach ( $assets as $asset ) {
            if ( ! is_array( $asset ) || empty( $asset['filename'] ) ) {
                continue;
            }
            $filename                 = Costudio_Visual_Plan::normalize_filename( (string) $asset['filename'] );
            $by_filename[ $filename ] = array(
                'allows_data_visualization' => ! empty( $asset['allows_data_visualization'] ),
                'verified_fact_count'       => is_array( $asset['verified_facts'] ?? null ) ? count( $asset['verified_facts'] ) : 0,
            );
        }

        self::visual_log( 'info', 'visual.context_packet_built', array(
            'production_id' => $production_id,
            'asset_count'   => count( $by_filename ),
        ), $production_id );

        return $by_filename;
    }

    /**
     * The costudio_visual_plan_repair_output callback (registered in the
     * constructor). Makes the single allowed repair call — a short,
     * self-contained prompt built only from the rejected asset IDs/fields/
     * rules that Costudio_Visual_Plan::repair_payload() produced, never the
     * full article/context/other assets — and returns the raw text response
     * for finalize_visual_plan_output() to re-parse and re-validate.
     *
     * Never throws: any failure here must fall back exactly like an
     * unregistered filter would (return $default, i.e. null), because the
     * caller already treats that as "no repair, use the safe fallback" and
     * a failed repair must never be the reason an article fails to publish.
     *
     * @param mixed $default The filter's default (always null on entry).
     * @param array $repair  Costudio_Visual_Plan::repair_payload() shape.
     * @param array $meta    ['production_id' => int, 'agent_id' => int].
     * @return string|null Raw model text, or $default when nothing to repair or the call failed.
     */
    public function attempt_visual_plan_repair($default, array $repair, array $meta)
    {
        if ( empty( $repair['rejected_asset_ids'] ) && empty( $repair['rejected'] ) ) {
            return $default;
        }

        $production_id = (int) ( $meta['production_id'] ?? 0 );
        $started_ms    = (int) ( microtime( true ) * 1000 );

        self::visual_log( 'info', 'visual.prompt_compiled', array(
            'production_id'  => $production_id,
            'call'           => 'repair_generation',
            'provider'       => $this->provider,
            'rejected_count' => count( $repair['rejected_asset_ids'] ?? array() ),
        ), $production_id );

        $prompt = $this->build_visual_plan_repair_prompt( $repair );
        $result = '';
        $status = 'exception';

        try {
            if ( class_exists( 'Costudio_Circuit_Breaker' ) ) {
                Costudio_Circuit_Breaker::check( $this->provider, $this->api_key );
            }
            if ( class_exists( 'Costudio_Rate_Limiter' ) ) {
                Costudio_Rate_Limiter::wait( $this->provider, $this->api_key );
            }
            $result = (string) $this->client->generate( $prompt, array() );
            if ( class_exists( 'Costudio_Circuit_Breaker' ) ) {
                Costudio_Circuit_Breaker::record_success( $this->provider, $this->api_key );
            }
            $status = '' !== trim( $result ) ? 'ok' : 'empty';
        } catch ( \Throwable $e ) {
            $status = 'exception';
        }

        self::visual_log( 'exception' === $status ? 'warning' : 'info', 'visual.asset_generation_result', array(
            'production_id' => $production_id,
            'call'          => 'repair_generation',
            'provider'      => $this->provider,
            'result'        => $status,
            'duration_ms'   => (int) ( microtime( true ) * 1000 ) - $started_ms,
        ), $production_id );

        return 'ok' === $status ? $result : $default;
    }

    /**
     * Deliberately minimal — the model is NOT re-shown the article, the
     * other assets, or anything beyond what actually failed. Repeating full
     * context on a repair call would both cost more and give the model room
     * to regenerate assets that already passed validation.
     */
    private function build_visual_plan_repair_prompt( array $repair ): string
    {
        $json = function_exists( 'wp_json_encode' )
            ? wp_json_encode( $repair, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
            : json_encode( $repair );

        return "You previously returned a VISUAL_PLAN for an article's images. Deterministic "
            . "validation rejected only the assets/fields listed below — every other asset you "
            . "already produced was accepted and is NOT included here on purpose; do not restate "
            . "or regenerate anything beyond what is listed.\n\n"
            . "Return exactly one corrected block in the same format as before:\n"
            . "<VISUAL_PLAN>\n{ ...corrected JSON... }\n</VISUAL_PLAN>\n\n"
            . "Rejected assets, fields and the rule each one violated:\n" . $json;
    }

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

    private function local_prompt_looks_crosswired(string $prompt_template, int $agent): bool
    {
        $text = trim($prompt_template);
        if ($text === '' || strpos($text, '[Conteúdo bloqueado') !== false) {
            return false;
        }

        $expected = sprintf('%02d', $agent);
        $head     = mb_substr($text, 0, 900);

        if (preg_match('/\bAGENT(?:E)?\s+0?([1-7])\b/iu', $head, $m)) {
            $declared = str_pad((string) $m[1], 2, '0', STR_PAD_LEFT);
            if ($declared !== $expected) {
                return true;
            }
        }

        if ($agent !== 4) {
            return false;
        }

        $sample = mb_substr($text, 0, 8000);

        return preg_match('/\b(auditar|audit|auditar el)\b.{0,120}\b(artigo|article|articulo)\b.{0,120}\bAGENT(?:E)?\s+0?4\b/ius', $sample) === 1
            || (
                preg_match('/FORMATO\s+DE\s+OUTPUT\s*\(\s*JSON\s+puro\s*\)/iu', $sample) === 1
                && preg_match('/`?reasoning_trace`?|"reasoning_trace"/iu', $sample) === 1
                && preg_match('/`?diagnostic_score`?|"diagnostic_score"/iu', $sample) === 1
            );
    }

    private function assert_valid_writer_output(string $output, string $article_type, array $inputs, int $production_id): void
    {
        if ($article_type === Costudio_Article_Types::UPDATE) {
            return;
        }

        if ($this->looks_like_reviewer_json_output($output)) {
            self::pipeline_log('error', 'agent4.invalid_reviewer_json', array(
                'production_id' => $production_id,
                'article_type'  => $article_type,
                'output_chars'  => strlen($output),
                'head_300'      => mb_substr($output, 0, 300),
            ));

            throw new Exception(
                __('O Agente 04 retornou JSON de revisão editorial em vez de artigo Markdown. A publicação foi bloqueada para evitar criar um post quebrado. Regrave o prompt do Agent 04 no ContentOS App e use "Reprocessar daqui" a partir do Agente 04.', 'contentosapp-studio')
            );
        }

        $has_heading = preg_match('/^##\s+\S+/mu', $output) === 1;
        $has_metadata = preg_match('/^\s*(Título|Title|Título):\s+/iu', $output) === 1
            && preg_match('/^\s*Meta Description:\s+/imu', $output) === 1;

        $limits = $this->parse_extensao($inputs['extensao'] ?? '2000-2600');
        $wc = $this->count_words($output);
        $hard_min = max(600, (int) floor(((int) ($limits['min'] ?? 2000)) * 0.25));

        if (! $has_heading && ! $has_metadata && $wc < $hard_min) {
            self::pipeline_log('error', 'agent4.invalid_article_shape', array(
                'production_id' => $production_id,
                'article_type'  => $article_type,
                'wc'            => $wc,
                'hard_min'      => $hard_min,
                'has_heading'   => $has_heading,
                'has_metadata'  => $has_metadata,
                'head_300'      => mb_substr($output, 0, 300),
            ));

            throw new Exception(sprintf(
                /* translators: %1$d: word count */
                __('O Agente 04 retornou um conteúdo com formato inválido para artigo (%1$d palavras, sem metadados e sem H2). A publicação foi bloqueada. Use "Reprocessar daqui" a partir do Agente 04.', 'contentosapp-studio'),
                $wc
            ));
        }
    }

    /**
     * Step 8: Final publishing to WP target site.
     *
     * Chunked (Phase 2): the publisher generates at most ONE new image per call
     * and returns ['pending' => true] when more images remain. In that case this
     * method returns TRUE so run_step() re-dispatches the publish step (one HTTP
     * request per image — never accumulating several slow generations in a single
     * request the web server would kill). When no images remain, the publisher
     * assembles + creates the post and this method returns FALSE (production done).
     *
     * @return bool TRUE = more images pending (re-dispatch the publish step).
     */
    public static function execute_publication_job(int $production_id): bool
    {
        $production = Costudio_Production::find($production_id);
        if (! $production) {
            return false;
        }

        // Idempotency guard: if already published, done.
        if (
            ! empty($production->wp_post_id)
            && (int) $production->wp_post_id > 0
            && ! Costudio_Publication_Ledger::needs_resume($production_id)
        ) {
            if ('completed' !== (string) $production->status) {
                Costudio_Production::set_completed(
                    $production_id,
                    (int) $production->wp_post_id,
                    (string) ($production->wp_post_url ?? ''),
                    (string) ($production->edit_url ?? ''),
                    (string) ($production->title ?? '')
                );
            }
            return false;
        }

        $inputs = json_decode($production->inputs ?? '{}', true);
        if (! is_array($inputs)) {
            $inputs = [];
        }

        $version = $production->get_pipeline_version();
        $designer_step = ($version === 7) ? 6 : 5;

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

        $publisher = new Costudio_WP_Publisher($production->site_key);
        $result    = $publisher->publish([
            'output_02' => $production->output_02 ?? '',
            'output_04' => $production->get_agent_output(4) ?? '',
            'output_05' => $production->get_agent_output($designer_step) ?? '',
            'inputs'    => $inputs,
            'site_key'  => $production->site_key,
            'production_id' => $production_id,
            'chunked'   => true,
        ]);

        // Chunked: one image was generated this pass and more remain — keep the
        // heartbeat fresh and ask the runner to re-dispatch the publish step.
        if (! empty($result['pending'])) {
            if (class_exists('Costudio_Production')) {
                Costudio_Production::update_heartbeat($production_id);
            }
            return true;
        }

        $image_warnings = '';
        if (! empty($result['image_errors']) && is_array($result['image_errors'])) {
            $image_warnings = implode(' | ', $result['image_errors']);
        }

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

        if (class_exists('Costudio_Runner')) {
            Costudio_Runner::pipeline_log_append(
                $production_id,
                __( 'Post criado no WordPress. A concluir a produção…', 'contentosapp-studio' ),
                'ok'
            );
        }

        Costudio_Production::set_completed(
            $production_id,
            (int) ($result['post_id'] ?? 0),
            (string) ($result['post_url'] ?? ''),
            (string) ($result['edit_url'] ?? ''),
            (string) ($result['title'] ?? ''),
            $image_warnings
        );

        return false;
    }

    // ── Prompt interpolation ─────────────────────────────────────────────────

    /**
     * BYOK/local execution requires local prompt templates. If a site somehow
     * has empty `costudio_agent_XX` options (fresh install, failed seed, or
     * stale migration), try one forced sync before failing the production.
     */
    private function attempt_prompt_resync(int $agent, string $lang, int $production_id): string
    {
        if (! class_exists('Costudio_Agent_Seeder')) {
            $seeder_path = defined('COSTUDIO_DIR') ? COSTUDIO_DIR . 'includes/class-costudio-agent-seeder.php' : '';
            if ($seeder_path && file_exists($seeder_path)) {
                require_once $seeder_path;
            }
        }

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

        self::pipeline_log('info', 'pipeline.prompt_resync.start', array(
            'production_id' => $production_id,
            'agent'         => $agent,
            'locale'        => $lang,
        ));

        try {
            $synced = Costudio_Agent_Seeder::force_sync_from_vault($lang);
        } catch (\Throwable $e) {
            self::pipeline_log('warning', 'pipeline.prompt_resync.exception', array(
                'production_id' => $production_id,
                'agent'         => $agent,
                'error'         => $e->getMessage(),
            ));
            return '';
        }

        $prompt = (string) costudio_get_option(sprintf('costudio_agent_%02d', $agent), '');

        self::pipeline_log($synced && $prompt !== '' ? 'info' : 'warning', 'pipeline.prompt_resync.result', array(
            'production_id' => $production_id,
            'agent'         => $agent,
            'synced'        => (bool) $synced,
            'prompt_loaded' => $prompt !== '',
        ));

        return $prompt;
    }

    /**
     * Busca o prompt completo de um agente para execução BYOK.
     *
     * Chamado quando o cache local contém apenas um teaser legado.
     * O prompt retornado é efêmero — NÃO é gravado no DB, portanto o teaser
     * exibido no painel admin permanece intacto.
     *
     * @return string Texto completo do prompt, ou '' se o fetch falhar.
     */
    private function fetch_execution_prompt( int $agent, string $lang, int $production_id ): string {
        if ( ! class_exists( 'Costudio_Agent_Seeder' ) ) {
            $seeder_path = defined( 'COSTUDIO_DIR' ) ? COSTUDIO_DIR . 'includes/class-costudio-agent-seeder.php' : '';
            if ( $seeder_path && file_exists( $seeder_path ) ) {
                require_once $seeder_path;
            }
        }

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

        self::pipeline_log( 'info', 'pipeline.byok_execution_prompt_fetch.start', [
            'production_id' => $production_id,
            'agent'         => $agent,
            'locale'        => $lang,
        ] );

        $prompt = Costudio_Agent_Seeder::fetch_single_for_execution( $agent, $lang );

        self::pipeline_log( $prompt !== '' ? 'info' : 'warning', 'pipeline.byok_execution_prompt_fetch.result', [
            'production_id' => $production_id,
            'agent'         => $agent,
            'fetched'       => $prompt !== '',
            'chars'         => strlen( $prompt ),
        ] );

        return $prompt;
    }

    private function interpolate(string $template, array $inputs): string
    {
        return Costudio_Pipeline_Input_Mapper::interpolate($template, $inputs, self::WORD_COUNT_CALIBRATION);
    }

    /**
     * Prepares the replacement map for prompt variables.
     * Shared between local interpolation and secure remote execution.
     */
    private function map_inputs_for_secure_execution(array $inputs): array
    {
        return Costudio_Pipeline_Input_Mapper::map_inputs_for_secure_execution($inputs, self::WORD_COUNT_CALIBRATION);
    }

    /**
     * Authoritative output-language lock prepended to EVERY agent prompt
     * (both BYOK and ContentOS Auto / secure paths).
     *
     * Root cause this guards against: the brand-voice samples and site context
     * are captured from the destination site (often Portuguese) and prepended at
     * the FRONT of the Strategist/Writer prompts. A bare `[LANGUAGE]` token inside
     * the canonical template was too weak to override those few-shot signals, so
     * Auto productions silently flipped the reader-facing strings (title, slug,
     * meta, headings, FAQ, body) to the site's language. This block states the
     * output language as NON-NEGOTIABLE and explicitly subordinates the brand
     * voice examples, the site name, and any source/reference language to it.
     */
    private function language_lock_block(string $lang_name): string
    {
        $lang_name = trim($lang_name) !== '' ? trim($lang_name) : 'English (US)';

        return sprintf(
            /* translators: %1$s: content language name (e.g. "English (US)"). */
            __(
                '## OUTPUT LANGUAGE — NON-NEGOTIABLE
Write EVERY reader-facing string in %1$s: the title, URL slug, meta description, all headings, body text, tables, lists, FAQ questions AND answers, image captions, alt text, and social copy.
This rule OVERRIDES everything else — the language of these instructions, the site name, the brand-voice examples below, and the language of any source, reference, or competitor. Brand-voice examples guide TONE and RHYTHM ONLY; never copy their language.
If any brief, outline, heading, or prior draft you receive is NOT in %1$s, translate it into %1$s. Do not switch languages because of the topic, region, currency, or examples. Output ONLY in %1$s.',
                'contentosapp-studio'
            ),
            $lang_name
        );
    }

    /**
     * Lightens a hex color to be used as a soft background.
     */
    private function get_soft_color(string $hex): string
    {
        return Costudio_Pipeline_Input_Mapper::get_soft_color($hex);
    }

    /**
     * Returns a hex color pair (main/soft) for the UI based on the content niche.
     * Defaulting to professional gray/blue for commercial version.
     */
    private function get_nicho_color(string $nicho, string $type = 'main'): string
    {
        return Costudio_Pipeline_Input_Mapper::get_nicho_color($nicho, $type);
    }

    /**
     * Build the universal article-type context block.
     *
     * This block carries TYPE IDENTIFICATION and STRUCTURAL DATA only (items,
     * sources, original article content for updates). All imperative behaviour
     * ("must include X", "avoid Y", per-agent instructions) lives in the agent
     * prompts themselves; users editing a prompt must see every
     * rule the agent receives.
     */
    private function build_type_context(array $inputs): string
    {
        return Costudio_Pipeline_Input_Mapper::build_type_context($inputs);
    }

    /**
     * Parse "2000-2600" (or "2000 - 2600") to min, max, target, and calibrated
     * prompt values (accounting for LLM tendency to overshoot).
     */
    private function parse_extensao(?string $extensao): array
    {
        return Costudio_Pipeline_Input_Mapper::parse_extensao($extensao, self::WORD_COUNT_CALIBRATION);
    }

    private function get_word_count_calibration(): float
    {
        return Costudio_Pipeline_Input_Mapper::get_word_count_calibration(self::WORD_COUNT_CALIBRATION);
    }

    private function count_words(string $text): int
    {
        if (class_exists('Costudio_Content_Word_Count')) {
            return Costudio_Content_Word_Count::count_editorial_words($text);
        }

        return Costudio_Pipeline_Input_Mapper::count_words($text);
    }

    /**
     * Get partial streaming output for a production (if currently streaming).
     *
     * @param  int $production_id
     * @return array|null  { agent: int, text: string } or null if not streaming.
     */
    public static function get_stream_partial(int $production_id): ?array
    {
        $raw = get_transient(self::STREAM_TRANSIENT_PREFIX . $production_id);
        if (empty($raw)) {
            return null;
        }
        $data = json_decode($raw, true);
        if (! is_array($data) || empty($data['text'])) {
            return null;
        }
        return [
            'agent' => (int) ($data['agent'] ?? 0),
            'text'  => $data['text'],
        ];
    }

    private function build_input_context(array $inputs): string
    {
        return Costudio_Pipeline_Input_Mapper::build_input_context($inputs);
    }

    /**
     * Set a per-agent thinking budget on adapters that support it.
     * Agents 5, 6 and 7 are structural/creative and don't need deep reasoning.
     */
    private function apply_thinking_budget(int $agent): void
    {
        $budgets = (array) apply_filters('costudio_agent_thinking_budgets', [
            1 => 2048,
            2 => 4096,
            3 => 2048,
            4 => 8192,
            5 => 1024,
            6 => 1024,
            7 => 1024,
        ]);

        $budget = isset($budgets[$agent]) ? (int) $budgets[$agent] : -1;
        $this->byok_attempt_thinking_budget = $budget;
        if (! method_exists($this->client, 'set_thinking_budget')) {
            return;
        }
        $this->client->set_thinking_budget($budget);
    }

    /**
     * Apply the per-agent temperature Adjustment on BYOK adapters that support it.
     * Only set when the user saved a value — otherwise the adapter keeps its own
     * default. ContentOS Auto receives temperature as a gated field in
     * generate_via_agent instead (Pro/Studio only), so this is BYOK-only.
     */
    private function apply_temperature(int $agent): void
    {
        $this->byok_attempt_temperature = null;
        if (! method_exists($this->client, 'set_temperature')) {
            return;
        }

        $raw = costudio_get_option(sprintf('costudio_agent_%02d_temperature', $agent), '');
        if ('' === $raw) {
            return; // No per-agent override — keep the adapter's default.
        }

        $temperature = max(0.0, min(2.0, (float) $raw));
        $this->byok_attempt_temperature = $temperature;
        $this->client->set_temperature($temperature);
    }

    // ── Context builder ──────────────────────────────────────────────────────

    /**
     * Builds the conversation history/context for the current agent.
     *
     * Optimization: We don't send EVERYTHING. We only send what's relevant to
     * the current agent to save tokens and reduce latency.
     */
    private function build_context(int $agent, object $production, array $inputs, int $grounded_offset = 0): array
    {
        $nicho   = $inputs['nicho']   ?? 'o site';
        $keyword = $inputs['keyword'] ?? '';

        $ctx = [
            [
                'role'    => 'user',
                'content' => "Você está produzindo conteúdo para {$nicho}. Keyword principal: \"{$keyword}\".",
            ],
        ];

        // UPDATE mode: skip Agent 02 (Strategist) and Agent 03 (Researcher) outputs
        // from Agent 04's context. Those outputs describe writing a NEW article and
        // directly contradict the UPDATE override injected via build_type_context(),
        // causing the LLM to produce editorial notes instead of revised prose.
        $is_update = $agent === 4
            && ('update' === ($inputs['article_type'] ?? ''));

        $requirements = [
            4 => $is_update
                ? ['revision_round_context']
                : [2, 3, 'revision_round_context'], // Redator needs Strategy + Research + potential Feedback.
            5 => ['full_article_04'],             // Reviewer needs FULL article.
            6 => ['summary_04_designer'],          // Designer needs headings + [IMG:] markers only.
            7 => ['summary_04_social'],            // Social needs title + key points only.
        ];

        $needed = $requirements[$agent] ?? [];

        foreach ($needed as $step) {
            if (is_int($step)) {
                $col    = $production->map_step_to_column($step);
                $output = $col ? ($production->$col ?? null) : null;

                if (! empty($output)) {
                    $label  = self::AGENT_LABELS[$step] ?? "Agent {$step}";
                    // Token guard: Agent 03 doesn't need the full Strategist output.
                    if ($agent === 3 && $step === 2 && strlen($output) > self::CONTEXT_MAX_CHARS_AGENT03_STEP2) {
                        $output = substr($output, 0, self::CONTEXT_MAX_CHARS_AGENT03_STEP2)
                            . "\n\n---\nNOTE: Strategist output truncated for performance.\n";
                    }
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== OUTPUT DO AGENTE {$step} — {$label} ===\n\n{$output}",
                    ];
                }
            } elseif ('summary_01' === $step) {
                $full = $production->output_01 ?? '';
                if (! empty($full)) {
                    $summary = $this->summarize_discovery($full);
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== RESUMO DO AGENTE 1 — Descobridor (itens-chave) ===\n\n{$summary}",
                    ];
                }
            } elseif ('full_article_04' === $step) {
                $full_article = $production->output_04 ?? '';
                if (! empty($full_article)) {
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== ARTIGO COMPLETO (AGENTE 4) ===\n\n{$full_article}",
                    ];
                }
            } elseif ('summary_04' === $step || 'summary_04_designer' === $step) {
                $full_article = $production->output_04 ?? '';
                if (! empty($full_article)) {
					$summary = $this->summarize_article_for_designer(
						$full_article,
						$inputs,
						(string) ( $production->output_01 ?? '' ),
						(string) ( $production->output_03 ?? '' )
					);
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== ESTRUTURA DO ARTIGO (AGENTE 4) — Headings + marcadores [IMG:] ===\n\n{$summary}",
                    ];
                }
            } elseif ('summary_04_social' === $step) {
                $full_article = $production->output_04 ?? '';
                if (! empty($full_article)) {
                    $summary = $this->summarize_article_for_social($full_article);
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== RESUMO DO ARTIGO (AGENTE 4) — Para criação de posts sociais ===\n\n{$summary}",
                    ];
                }
            } elseif ('revision_round_context' === $step) {
                // If we are in a revision round, we have a previous draft and feedback in inputs.
                $last_draft    = $inputs['last_draft'] ?? '';
                $last_feedback = $inputs['last_reviewer_feedback'] ?? [];

                if (! empty($last_draft)) {
                    $ctx[] = [
                        'role'    => 'model',
                        'content' => "=== VERSÃO ANTERIOR DO ARTIGO (PARA REFATORAÇÃO) ===\n\n{$last_draft}",
                    ];
                }

                if (! empty($last_feedback) && is_array($last_feedback)) {
                    $msg = "=== FEEDBACK DO REVISOR EDITORIAL (RODADA DE REVISÃO) ===\n\n";
                    $msg .= "VEREDITO: Reprovado para ajustes.\n";
                    $msg .= "FEEDBACK: " . ($last_feedback['feedback'] ?? 'Ajustes necessários.') . "\n";
                    if (! empty($last_feedback['fixes'])) {
                        $msg .= "AÇÕES NECESSÁRIAS:\n- " . implode("\n- ", (array)$last_feedback['fixes']);
                    }
                    $ctx[] = [
                        'role'    => 'user',
                        'content' => $msg,
                    ];
                }
            }
        }

        // Links/references context (available for all agents).
        // Citation agents (3 Researcher, 4 Writer) get the EXTRACTED CONTENT of
        // the user's external links (read, not just cited), numbered with the
        // exact {{ref:N}} offset (after the grounded pool) so citations resolve.
        if (! empty($production->links_internos)) {
            $read_content = in_array($agent, [3, 4], true);
            $formatted    = $this->format_links_for_prompt(
                (string) $production->links_internos,
                $grounded_offset,
                $read_content
            );
            if ($formatted !== '') {
                $ctx[] = [
                    'role'    => 'user',
                    'content' => "=== REFERÊNCIAS DISPONÍVEIS PARA O ARTIGO ===\n\n{$formatted}",
                ];
            }
        }

        // Video candidate context (Item 3, subitem 3A). Strategist (A02)
        // decides IF/WHERE it fits editorially; Writer (A04) is the one that
        // actually emits the marker. No other agent needs this. `update`
        // productions are EXCLUDED: publish_update() never resolves
        // [VIDEO:id] markers (it preserves the already-published post and
        // must not touch existing embeds), so a marker the Writer emitted
        // there would survive to the QA gate as a residual token and block
        // the entire update. Simplest safe fix: never offer the candidate
        // to an update production in the first place.
        $article_type_for_video = Costudio_Article_Types::normalize($inputs['article_type'] ?? '');
        if (in_array($agent, [2, 4], true) && Costudio_Article_Types::UPDATE !== $article_type_for_video) {
            $video_candidate = $this->resolve_video_candidate($production, $inputs);

            // Item 3, subitem 3B (automatic discovery) — only for the Writer
            // (needs A02's VIDEO_OPPORTUNITY, which doesn't exist until A02 has
            // already run), and only when 3A did NOT already produce a working
            // candidate. The user's manual link always wins; discovery never
            // even attempts when it doesn't need to.
            if (4 === $agent && empty($video_candidate['ok']) && class_exists('Costudio_Video_Discovery') && isset($production->id)) {
                // Keep $inputs['video_candidate'] in sync with what
                // resolve_video_candidate() just persisted. That method takes
                // $inputs by value, mutates its OWN local copy, and writes
                // that straight to the DB — this build_context() copy is
                // never updated. Without this line, an early persist_terminal()
                // inside attempt() (e.g. HELPS=NO, search disabled) would
                // overwrite the whole inputs JSON using this stale copy and
                // silently erase the negative 3A result that was just
                // committed a few lines above (Codex review, 2026-08-20).
                $inputs['video_candidate'] = $video_candidate;

                // Discovery is best-effort and optional — a network/DB fault
                // in here must never abort the Writer's whole context build
                // (Codex review, 2026-08-20: the direct call had no exception
                // containment at all).
                try {
                    $video_candidate = Costudio_Video_Discovery::attempt((int) $production->id, $production, $inputs);
                } catch (\Throwable $e) {
                    if (class_exists('Costudio_Logger') && method_exists('Costudio_Logger', 'warn')) {
                        // Never log the raw exception message — it can carry
                        // a query, URL, or provider parameter (Codex review,
                        // 2026-08-20). A class + hash is enough to correlate
                        // occurrences without exposing the content.
                        $context = ['production_id' => (int) $production->id];
                        if (class_exists('Costudio_Video_Discovery') && method_exists('Costudio_Video_Discovery', 'sanitize_exception_for_log')) {
                            $context += Costudio_Video_Discovery::sanitize_exception_for_log($e);
                        }
                        Costudio_Logger::warn('pipeline.video_discovery.exception', $context);
                    }
                    $video_candidate = ['ok' => false, 'error' => 'discovery_exception'];
                }
            }

            if (! empty($video_candidate['ok'])) {
                $video_block = Costudio_Pipeline_Links::format_video_candidate_for_prompt($video_candidate);
                if ('' !== $video_block) {
                    $ctx[] = [
                        'role'    => 'user',
                        'content' => $video_block,
                    ];
                }
            }
        }

        return $ctx;
    }

    /**
     * Resolve (and persist) this production's video candidate exactly once.
     * Reused verbatim by publish() later, via inputs['video_candidate'], so
     * the publisher validates the Writer's marker against the SAME candidate
     * the Writer was shown — never a value re-derived at publish time.
     *
     * Tries up to Costudio_Pipeline_Links::VIDEO_CANDIDATE_TRY_LIMIT
     * syntactically-valid YouTube URLs, in the user's order, stopping at the
     * first one oEmbed actually confirms — a single private/removed/dead
     * link no longer silently kills the whole candidate when a later
     * external reference would have worked.
     *
     * @return array The Costudio_Link_Reader::read_video() shape of the
     *                first CONFIRMED candidate, or the last attempt's
     *                failure (or a generic no_candidate) when none work.
     */
    private function resolve_video_candidate(object $production, array $inputs): array
    {
        if (array_key_exists('video_candidate', $inputs) && is_array($inputs['video_candidate'])) {
            return $inputs['video_candidate'];
        }

        $parsed      = Costudio_Pipeline_Links::parse_links_internos((string) $production->links_internos);
        $video_urls  = Costudio_Pipeline_Links::find_video_candidate_urls((array) ($parsed['external'] ?? []));

        $candidate = ['ok' => false, 'error' => 'no_candidate'];
        if (class_exists('Costudio_Link_Reader')) {
            foreach ($video_urls as $video_url) {
                $attempt = Costudio_Link_Reader::read_video($video_url);
                $candidate = $attempt;
                if (! empty($attempt['ok'])) {
                    break;
                }
            }
        }

        $inputs['video_candidate'] = $candidate;
        if (class_exists('Costudio_Production') && isset($production->id)) {
            Costudio_Production::update_inputs((int) $production->id, $inputs);
        }

        return $candidate;
    }

    /**
     * Normalise the raw links_internos field into a consistent structure.
     *
     * Three cases:
     *   A — Empty / null  → ['internal' => [], 'external' => [], 'legacy' => false]
     *   B — Legacy string → ['internal' => [], 'external' => [], 'legacy' => <string>]
     *   C — New JSON      → ['internal' => [...], 'external' => [...], 'legacy' => false]
     *
     * Public so Costudio_References_Renderer can reuse the same parser when
     * loading the user's selected internal links and external references for
     * the unified Referências section.
     *
     * @param  string $raw  Raw DB value.
     * @return array{internal: array, external: array, legacy: string|false}
     */
    public static function parse_links_internos(string $raw): array
    {
        return Costudio_Pipeline_Links::parse_links_internos($raw);
    }

    /**
     * Build the full references block for injection into the Redator prompt.
     *
     * Handles all three cases from parse_links_internos():
     *   A (empty)  → returns ''
     *   B (legacy) → wraps the original string under a heading, unchanged
     *   C (new)    → builds "Links internos" + "Referências externas" sections
     *
     * @param  string $raw  Raw DB value of wp_costudio_productions.links_internos.
     * @return string       Formatted block, or '' if nothing to include.
     */
    private function format_links_for_prompt(string $raw, int $grounded_offset = 0, bool $read_content = false): string
    {
        return Costudio_Pipeline_Links::format_for_prompt($raw, $grounded_offset, $read_content);
    }

    /**
     * Extracts headings, [IMG:] markers, metadata lines AND the full FAQ
     * section (when present) for Agent 05.
     *
     * Saves ~2000 tokens vs sending the full article — but the FAQ section
     * is preserved verbatim because Agent 05 needs the real Q&A pairs to
     * build a valid FAQPage Schema. Without the FAQ in context, the
     * Designer either hallucinates questions (wrong schema) or skips the
     * FAQPage entirely.
     */
    private function summarize_article_for_designer( string $text, array $inputs = array(), string $discoverer_output = '', string $researcher_output = '' ): string
    {
        return Costudio_Pipeline_Summaries::summarize_article_for_designer( $text, $inputs, $discoverer_output, $researcher_output );
    }

    /**
     * Deterministic list of [IMG:] markers for Agent 05 (canonical filenames + alt).
     *
     * @return string [IMAGE_LIST] block or empty string.
     */
    private function build_image_list_block(string $text): string
    {
        return Costudio_Pipeline_Summaries::build_image_list_block($text);
    }

    /**
     * Rewrite [IMG:filename | Alt] to use a SEO slug derived from Alt (WordPress sanitize_title).
     * Colliding slugs get a numeric suffix before .webp. Idempotent for a stable article.
     *
     * @param string $keyword Fallback when Alt is empty.
     */
    private function normalize_img_markers_to_seo_filenames(string $output, string $keyword): string
    {
        return Costudio_Pipeline_Image_Markers::normalize_to_seo_filenames($output, $keyword);
    }

    /**
     * Make sure the article body contains at least `$desired` inline image
     * markers (`[IMG:imagemN.webp | Alt]`). When existing markers are missing
     * or insufficient, splice new ones in at the most natural junctions:
     * after the first paragraph of the H2 sections that don't already host
     * a marker, distributed evenly across the article.
     *
     * Numbering starts at imagem2.webp because imagem1 is reserved for the
     * cover (handled by Agent 05). Existing marker numbers are respected so
     * we never collide.
     *
     * Pure local operation — no LLM round-trip, no extra latency. Logs the
     * decision via `pipeline_log` so we have an audit trail per production.
     *
     * @param string $output         Full article markdown returned by Agent 04.
     * @param int    $desired        Target inline marker count (≥1).
     * @param string $keyword        Production keyword, used in synthesised Alt text.
     * @param int    $production_id
     * @return string                Article body with the missing markers inserted.
     */
    private function ensure_inline_image_markers(
        string $output,
        int $desired,
        string $keyword,
        int $production_id
    ): string {
        return Costudio_Pipeline_Image_Markers::ensure_inline_markers(
            $output,
            $desired,
            $keyword,
            $production_id,
            static function (string $level, string $event, array $payload): void {
                self::pipeline_log($level, $event, $payload);
            }
        );
    }

    /**
     * Extracts title, meta, headings, intro and key stats for Agent 06 (Social).
     * Saves ~1500 tokens vs sending the full article.
     */
    private function summarize_article_for_social(string $text): string
    {
        return Costudio_Pipeline_Summaries::summarize_article_for_social($text);
    }

    /**
     * Extracts PAA, gaps, search intent and LSI table from Agent 01.
     * Agent 03 does not need the full competitive analysis table.
     */
    private function summarize_discovery(string $text): string
    {
        return Costudio_Pipeline_Summaries::summarize_discovery($text);
    }
    /**
     * Scans agent output for markdown links and merges them into the production's 
     * structured sources list. This ensures that web search results from external 
     * providers (BYOK) are available for the {{ref:N}} citation system.
     *
     * Sanitisation is intentionally light — we only strip the small set of
     * trailing characters that LLMs reliably emit as artifacts of markdown
     * rendering (backticks from inline-code, leftover punctuation from prose).
     * We do NOT pre-judge whether a URL is "good enough" here; the heavy
     * lifting (reachability, soft-404 detection, deep→root fallback) is done
     * dynamically at render time in `Costudio_References_Renderer::resolve_ref()`.
     * That keeps every source candidate in the pool until the renderer has
     * real evidence (an HTTP response) about whether it works.
     */
    private function extract_and_merge_sources(int $production_id, string $content): void
    {
        Costudio_Pipeline_Links::extract_and_merge_sources($production_id, $content);
    }

    /**
     * Strip the narrow set of trailing characters that are unambiguously
     * LLM rendering artifacts:
     *
     *   - Backticks (`` ` ``) leak in when the model wrote the URL inside
     *     inline-code spans (`https://example.com/`).
     *   - Closing brackets/parens leak in from broken markdown
     *     (`[Title](https://example.com/]` or `(see https://example.com/))`).
     *
     * Anything else (periods, commas, etc.) is preserved — those CAN be part
     * of a legitimate URL path, and the renderer will validate the URL
     * dynamically before publishing it.
     */
    private static function clean_url_artifacts(string $url): string
    {
        return Costudio_Pipeline_Links::clean_url_artifacts($url);
    }
}
