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

/**
 * Costudio_ContentOS_Adapter — Proxy adapter for ContentOS App API.
 *
 * This adapter is used for ContentOS Auto, the managed service path. BYOK
 * uses the provider adapters directly and is not limited by this service quota.
 *
 * Remote flow (async):
 *   1. Submit job with X-FOS-Async header → receives 202 + job_id
 *   2. Poll GET /ai/job/{id} every 15s until completed/failed
 *
 * Local flow (same server):
 *   Calls CAP_Routes::handle_generate() directly via PHP — no HTTP involved.
 *
 * @package Contentosapp_Studio
 * @since   3.0.0
 */

defined('ABSPATH') || exit;

class Costudio_ContentOS_Adapter implements Costudio_AI_Client
{
    const API_HOST           = 'api.contentosapp.com';
    const API_ENDPOINT       = COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/ai/generate';
    const API_IMAGE_ENDPOINT = COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/ai/generate-image';
    const API_QUOTA_ENDPOINT = COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/quota';
    const API_AGENT_ENDPOINT = COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/ai/agent/generate';

    /** User-Agent de browser para requisições remotas (alguns WAF bloqueiam UA não-browser em /wp-json). */
    const REMOTE_BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';

    const SUBMIT_TIMEOUT = 30;
    const POLL_INTERVAL  = 15;

    /**
     * Maximum reported job age (seconds) before the client bails out.
     * Matches CAP_Jobs::MAX_PROCESSING_SECONDS on the server so both
     * sides detect stale jobs at roughly the same time.
     */
    // Must stay aligned with CAP_Jobs::MAX_PROCESSING_SECONDS on the server.
    const MAX_JOB_AGE    = 600;

    /**
     * Max wall-clock seconds a single poll_for_result() call is allowed to
     * block before yielding back via Costudio_Generation_Deferred_Exception.
     * Kept well under common PHP-FPM/webserver execution timeouts (many hosts
     * default to 60-300s) so a long Writer/Pillar job — or a stretch of 5xx
     * retry backoff while the ContentOS API origin is briefly out of workers
     * — never keeps a single HTTP request open long enough to be killed by
     * the local hosting environment before the remote job actually finishes.
     */
    const POLL_CHUNK_BUDGET = 45;

    /** Transient TTL for persisted pending-job state between chunks. */
    const PENDING_JOB_TTL = 20 * MINUTE_IN_SECONDS;

    private string $license_key;
    private string $provider;
    private int $thinking_budget = -1;
    private int $production_id   = 0;
    private bool $grounding_disabled = false;

    /**
     * @var array<int,array<string,mixed>>
     * Sources returned by the last successful remote call (Phase 4 fallback).
     * Populated when /ai/agent/generate ran server-side grounding for the
     * Researcher and the response payload included a `sources` field.
     * The pipeline reads this via get_last_sources() and persists it to
     * `sources_json` only when the local pipeline_search hadn't already
     * done so (avoiding duplicate writes with stale data).
     */
    private array $last_sources = [];

    public function __construct(string $license_key, string $provider = 'gemini')
    {
        $this->license_key = $license_key;
        $this->provider    = $provider;
    }

    public function set_thinking_budget(int $budget): void
    {
        $this->thinking_budget = $budget;
    }

    public function set_grounding_disabled(bool $disabled): void
    {
        $this->grounding_disabled = $disabled;
    }

    public function set_production_id(int $id): void
    {
        $this->production_id = $id;
    }

    // ── Chunked-poll resume state (Costudio_Generation_Deferred_Exception) ──

    private function pending_job_transient_key(): string
    {
        return 'costudio_pending_job_' . $this->production_id;
    }

    /**
     * @return array{job_id:string,poll_token:string,consecutive_5xx:int,sleep_seconds:int,last_status:string,first_seen_at:int}|null
     */
    private function get_pending_job(): ?array
    {
        if ($this->production_id <= 0) {
            return null;
        }
		if ( class_exists( 'Costudio_Remote_Jobs' ) ) {
			$production = Costudio_Production::get_by_id( $this->production_id );
			$agent_id   = $production ? max( 1, (int) ( $production->current_agent ?? 1 ) ) : 0;
			$durable    = $agent_id > 0 ? Costudio_Remote_Jobs::current( $this->production_id, $agent_id ) : null;
			if ( is_array( $durable ) && ! empty( $durable['job_id'] ) && ! empty( $durable['poll_token'] ) ) {
				$resume = is_array( $durable['resume'] ?? null ) ? $durable['resume'] : [];
				return [
					'job_id'          => (string) $durable['job_id'],
					'poll_token'      => (string) $durable['poll_token'],
					'consecutive_5xx' => (int) ( $resume['consecutive_5xx'] ?? 0 ),
					'sleep_seconds'   => (int) ( $resume['sleep_seconds'] ?? self::POLL_INTERVAL ),
					'last_status'     => (string) ( $resume['last_status'] ?? $durable['state'] ),
					'first_seen_at'   => (int) ( $resume['first_seen_at'] ?? ( strtotime( (string) $durable['first_seen_at'] . ' UTC' ) ?: time() ) ),
					'attempt_id'      => (string) $durable['attempt_id'],
					'queue_version'   => (int) $durable['queue_version'],
				];
			}
		}
        $value = get_transient($this->pending_job_transient_key());
        return (is_array($value) && ! empty($value['job_id']) && ! empty($value['poll_token'])) ? $value : null;
    }

    private function save_pending_job(array $state): void
    {
        if ($this->production_id <= 0) {
            return;
        }
		if ( 2 === (int) ( $state['queue_version'] ?? 0 ) && ! empty( $state['attempt_id'] ) ) {
			$observed = (string) ( $state['last_status'] ?? 'pending' );
			if ( ! in_array( $observed, [ 'pending', 'processing', 'transport_wait' ], true ) ) {
				$observed = 'pending';
			}
			Costudio_Remote_Jobs::observe( (string) $state['attempt_id'], $observed );
			Costudio_Remote_Jobs::save_resume_state( (string) $state['attempt_id'], $state );
			return;
		}
		set_transient($this->pending_job_transient_key(), $state, self::PENDING_JOB_TTL);
    }

    private function clear_pending_job(): void
    {
        if ($this->production_id <= 0) {
            return;
        }
		$pending = $this->get_pending_job();
		if ( is_array( $pending ) && 2 === (int) ( $pending['queue_version'] ?? 0 ) && ! empty( $pending['attempt_id'] ) ) {
			Costudio_Remote_Jobs::mark_transport_wait( (string) $pending['attempt_id'] );
			return;
		}
		delete_transient($this->pending_job_transient_key());
    }

	private function finish_pending_job( string $state ): void {
		$pending = $this->get_pending_job();
		if ( is_array( $pending ) && 2 === (int) ( $pending['queue_version'] ?? 0 ) && ! empty( $pending['attempt_id'] ) ) {
			Costudio_Remote_Jobs::observe( (string) $pending['attempt_id'], $state );
			return;
		}
		delete_transient( $this->pending_job_transient_key() );
	}

    public function generate(string $prompt, array $context = []): string
    {
        return $this->call_proxy(self::API_ENDPOINT, [
            'prompt'      => $prompt,
            'context'     => $context,
            'with_search' => false,
        ]);
    }

    public function generateWithSearch(string $prompt, array $context = []): string
    {
        return $this->call_proxy(self::API_ENDPOINT, [
            'prompt'      => $prompt,
            'context'     => $context,
            'with_search' => true,
        ]);
    }

    public function generate_via_agent(int $agent_id, array $inputs, array $context = []): string
    {
        $pad   = str_pad( (string) $agent_id, 2, '0', STR_PAD_LEFT );
        $niche = (string) get_option( 'costudio_agent_' . $pad . '_niche', '' );
        $extra = (string) get_option( 'costudio_agent_' . $pad . '_extra', '' );
        $temp  = (string) get_option( 'costudio_agent_' . $pad . '_temperature', '' );
		$locale        = (string) ($inputs['lang_code'] ?? '');
		$language_name = (string) ($inputs['lang_name'] ?? ($inputs['[LANGUAGE]'] ?? ''));
		if (! Costudio_Admin::is_supported_content_language($locale) && '' !== $language_name) {
			$resolved = array_search($language_name, Costudio_Admin::get_supported_content_languages(), true);
			$locale   = false !== $resolved ? (string) $resolved : '';
		}
		if (! Costudio_Admin::is_supported_content_language($locale)) {
			$locale = Costudio_Admin::get_default_content_language();
		}

        $params = [
            'agent_id' => $agent_id,
            'inputs'   => $inputs,
            'context'  => $context,
            'locale'   => $locale,
        ];

        // Per-agent Adjustments. The server applies these on top of the canonical
        // prompt for Pro/Studio plans (Starter is ignored server-side; BYOK applies
        // them locally instead). The raw prompt is never sent — it stays on the server.
        if ( '' !== $niche ) {
            $params['agent_niche'] = $niche;
        }
        if ( '' !== $extra ) {
            $params['agent_extra'] = $extra;
        }
        if ( '' !== $temp ) {
            $params['agent_temperature'] = (float) $temp;
        }

        // Tell the server NOT to auto-ground (web search is off) so it doesn't
        // re-introduce external sources the user deliberately turned off.
        if ( $this->grounding_disabled ) {
            $params['grounding_disabled'] = true;
        }

        return $this->call_proxy(self::API_AGENT_ENDPOINT, $params);
    }

    // ── Core proxy call ──────────────────────────────────────────────────────

    private function call_proxy(string $endpoint, array $params): string
    {
        $body = array_merge([
            'provider' => $this->provider,
            'site_url' => get_site_url(),
        ], $params);

        if (class_exists('Costudio_Telemetry') && method_exists('Costudio_Telemetry', 'current_site_hash')) {
            $body['site_hash'] = Costudio_Telemetry::current_site_hash();
        }

        if ($this->thinking_budget >= 0) {
            $body['thinking_budget'] = $this->thinking_budget;
        }

        if ($this->production_id > 0) {
            $body['production_id'] = $this->production_id;
            if ( class_exists( 'Costudio_Production' ) ) {
                $production_uid = Costudio_Production::tracking_uid_for( $this->production_id );
                if ( '' !== $production_uid ) {
                    $body['production_uid'] = $production_uid;
                }
            }
			$agent_id = max( 1, (int) ( $body['agent_id'] ?? 0 ) );
			$attempt  = Costudio_Remote_Jobs::current_or_create( $this->production_id, $agent_id );
			$body['attempt_id'] = (string) $attempt['attempt_id'];
        }

        // Keep the client wait aligned with CAP_Jobs::MAX_PROCESSING_SECONDS.
        $timeout = (int) apply_filters('costudio_contentos_api_timeout', self::MAX_JOB_AGE + 60);

        $fingerprint = get_option('costudio_site_fingerprint');
        if (! $fingerprint) {
            $fingerprint = md5(get_site_url() . wp_generate_password(20, false));
            update_option('costudio_site_fingerprint', $fingerprint, false);
        }

        $headers = [
            'Content-Type'  => 'application/json',
            'Accept'        => 'application/json',
            'X-FOS-Site-ID' => $fingerprint,
        ];
		if ( defined( 'COSTUDIO_VERSION' ) ) {
			$headers['X-Costudio-Version'] = (string) COSTUDIO_VERSION;
		}
		if ( ! empty( $attempt ) ) {
			$headers['X-Costudio-Queue-Version']   = '2';
			$headers['X-Costudio-Idempotency-Key'] = (string) $attempt['idempotency_key'];
		}

        if (! empty($this->license_key)) {
            $headers['Authorization'] = 'Bearer ' . $this->license_key;
            
            // Sign the payload.
            if (class_exists('Costudio_Standalone_Crypto')) {
                $secret = class_exists('Costudio_Standalone_License')
                    ? Costudio_Standalone_License::get_signing_secret($this->license_key)
                    : $this->license_key;
                $headers['X-FOS-Signature'] = Costudio_Standalone_Crypto::sign_payload(wp_json_encode($body), $secret);
            }
        }

        if (defined('CAP_API_SERVER')) {
            $response = $this->call_local_proxy($endpoint, $body, $headers);
        } else {
            $response = $this->call_remote_proxy($endpoint, $body, $headers, $timeout);
        }

        if (is_wp_error($response)) {
            throw new Exception(__('ContentOS API connection error:', 'contentosapp-studio') . ' ' . $response->get_error_message());
        }

        return $this->parse_response($response);
    }

    // ── Response parsing ─────────────────────────────────────────────────────

    private function parse_response($response): string
    {
        if ($response instanceof WP_REST_Response) {
            $code = (int) $response->get_status();
            $data = $response->get_data();
            $data = is_array($data) ? $data : [];
            $raw  = wp_json_encode($data);
        } else {
            $code = (int) wp_remote_retrieve_response_code($response);
            $raw  = (string) wp_remote_retrieve_body($response);

            if (empty($raw)) {
                costudio_log('contentos.api_empty_body', ['http' => $code], 'warning');
                throw new Exception('ContentOS API returned HTTP ' . $code . ' with no body. Possible Cloudflare timeout (524).');
            }

            $data = json_decode($raw, true);

            if (! is_array($data)) {
                costudio_log_debug('contentos.api_non_json_response', [
                    'http' => $code,
					'body_length' => strlen( $raw ),
                ]);

                if (stripos($raw, 'cloudflare') !== false || stripos($raw, 'cf-ray') !== false) {
                    throw new Exception('Cloudflare is blocking this request (HTTP ' . $code . '). Check Bot Fight Mode and Security settings in Cloudflare dashboard.');
                }

                if (stripos($raw, 'bot verification') !== false || stripos($raw, 'imunify') !== false) {
                    throw new Exception('ContentOS API server is blocking automated requests (Imunify360 / Bot Verification). Ask the host to whitelist the ContentOS API REST and remote AJAX endpoints, or disable bot protection for those service URLs.');
                }

                throw new Exception('ContentOS API returned non-JSON (HTTP ' . $code . ').');
            }
        }

        if ($code !== 200) {
            $this->throw_api_error($code, $data, $raw);
        }

        $text = $data['output'] ?? '';

        if (empty($text)) {
            costudio_log_debug('contentos.api_empty_output', [
                'http'        => $code,
                'body_length' => strlen($raw),
            ]);
            throw new Exception('ContentOS API returned HTTP 200 but output is empty.');
        }

        // Capture server-side grounded sources (Phase 4 fallback). The pipeline
        // pulls these via get_last_sources() and persists to sources_json only
        // when its own pipeline_search hadn't already populated it.
        $this->last_sources = (! empty($data['sources']) && is_array($data['sources']))
            ? array_values($data['sources'])
            : [];

        return $text;
    }

    /**
     * Returns sources captured from the most recent successful response.
     * Empty when the server didn't run grounding (e.g., plugin pre-fetched).
     *
     * @return array<int,array<string,mixed>>
     */
    public function get_last_sources(): array
    {
        return $this->last_sources;
    }

    private function throw_api_error(int $code, array $data, string $raw): void
    {
        $msg        = $data['error']['message'] ?? $data['message'] ?? $raw;
        $error_code = $data['error']['code'] ?? '';

        if ($this->is_managed_provider_billing_error($msg)) {
            throw new Exception($this->managed_provider_unavailable_message());
        }

        if ($code === 429 && $error_code === 'quota_exceeded') {
            $err_msg     = ! empty($data['error']['message']) || ! empty($data['message'])
                ? $msg
                : __('ContentOS Auto managed-service quota reached. BYOK mode remains available with your own provider key.', 'contentosapp-studio');
            $upgrade_url = (string) ($data['error']['upgrade_url'] ?? '');
            throw new Costudio_Upgrade_Required_Exception('quota_exceeded', $err_msg, $upgrade_url);
        }

        if ($code === 429 && $error_code === 'production_call_limit') {
            if (! empty($data['error']['message']) || ! empty($data['message'])) {
                throw new Exception($msg);
            }
            throw new Exception(__('Per-article API limit reached. Please wait a few hours before reprocessing.', 'contentosapp-studio'));
        }

		if ( 426 === $code && 'client_update_required' === $error_code ) {
			throw new Exception(
				$msg ?: __( 'Update Contentosapp Studio before starting another ContentOS Auto production. This request did not consume quota or call the AI provider.', 'contentosapp-studio' )
			);
		}

        if ($code === 429) {
            throw new Exception(__('AI provider rate limit (429):', 'contentosapp-studio') . ' ' . $msg);
        }

        if ($code === 403 && $error_code === 'site_limit_exceeded') {
            $err_msg     = ! empty($data['error']['message']) || ! empty($data['message'])
                ? $msg
                : __('The ContentOS Auto service site activation limit was reached. BYOK publication destinations remain managed locally in the plugin.', 'contentosapp-studio');
            $upgrade_url = (string) ($data['error']['upgrade_url'] ?? '');
            throw new Costudio_Upgrade_Required_Exception('site_limit_exceeded', $err_msg, $upgrade_url);
        }

        if ($code === 401 || $code === 403) {
            throw new Exception(__('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'));
        }

		if ( 'job_result_uncertain' === $error_code ) {
			throw new Exception( __( 'The ContentOS provider request ended without a final confirmation. To prevent duplicated usage, it was not repeated automatically. Provider usage may have occurred; use “Retry Agent” only if you want to start a new attempt.', 'contentosapp-studio' ) );
		}
		if ( in_array( $error_code, [ 'job_cancelled', 'job_cancelled_after_dispatch' ], true ) ) {
			throw new Exception( __( 'This ContentOS Auto job was cancelled. If it had already reached the AI provider, provider usage may still have occurred.', 'contentosapp-studio' ) );
		}

        if ($code === 503) {
            throw new Exception(__('ContentOS AI backend (503):', 'contentosapp-studio') . ' ' . $msg);
        }

        throw new Exception("ContentOS API Error ({$code}): {$msg}");
    }

    private function is_managed_provider_billing_error(string $message): bool
    {
        $message = strtolower($message);
        $needles = [
            'prepayment credits are depleted',
            'billing#prepay',
            'manage your project and billing',
            'project and billing',
        ];

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

        return false;
    }

    private function managed_provider_unavailable_message(): string
    {
        return __('ContentOS Auto is temporarily unavailable because the managed AI provider needs attention. This failed attempt was not counted. Try again later or switch to BYOK with your own provider key.', 'contentosapp-studio');
    }

    // ── Local call (same server) ─────────────────────────────────────────────

    private function call_local_proxy(string $endpoint, array $body, array $headers)
    {
        if (! class_exists('CAP_Routes') && defined('CAP_DIR')) {
            $routes_file = CAP_DIR . 'includes/class-cap-routes.php';
            if (file_exists($routes_file)) {
                require_once $routes_file;
            }
        }

        if (! class_exists('CAP_Routes') || ! class_exists('WP_REST_Request')) {
            return new WP_Error('contentos_local_unavailable', 'Local ContentOS API route is unavailable.');
        }

        $headers['X-FOS-Async'] = '1';

        // Extract relative route from endpoint.
        $route = str_replace(COSTUDIO_API_URL, '', $endpoint);

        $request = new WP_REST_Request('POST', $route);
        foreach ($headers as $name => $value) {
            $request->set_header($name, (string) $value);
        }
        $request->set_body(wp_json_encode($body));

        // Route routing.
        if (strpos($route, '/ai/agent/generate') !== false) {
            $response = CAP_Routes::handle_agent_generate($request);
        } else {
            $response = CAP_Routes::handle_generate($request);
        }

        // If server accepted the job (202), enter local polling loop.
        if ($response instanceof WP_REST_Response && 202 === $response->get_status()) {
            $data = $response->get_data();
            $job_id     = (string) ($data['job_id'] ?? '');
            $poll_token = (string) ($data['poll_token'] ?? '');
            if ($job_id && $poll_token) {
                // Determine timeout (reusing the same logic as remote).
                $timeout = (int) apply_filters('costudio_contentos_api_timeout', self::MAX_JOB_AGE + 60);
                return $this->poll_local_for_result($job_id, $poll_token, $timeout);
            }
            if ($job_id) {
                return new WP_Error('contentos_missing_poll_token', 'ContentOS API accepted the job without returning polling credentials.');
            }
        }

        return $response;
    }

    /**
     * Local polling loop for asynchronous jobs.
     * Uses PHP calls instead of HTTP to avoid loopback issues.
     */
    private function poll_local_for_result(string $job_id, string $poll_token, int $max_seconds): WP_REST_Response
    {
        $start = time();
        $poll_count = 0;
        $last_status = 'unknown';
        $last_job_age = 0;

        while ((time() - $start) < $max_seconds) {
            sleep(self::POLL_INTERVAL);
            $poll_count++;

            $request = new WP_REST_Request('GET', '/contentosapp-api/v1/ai/job/' . $job_id);
            $request->set_url_params(['job_id' => $job_id]);
            $request->set_header('X-FOS-Poll-Token', $poll_token);

            $response = CAP_Routes::handle_job_status($request);
            $data     = $response->get_data();
            $status   = $data['status'] ?? '';
            $last_status  = (string) $status;
            $last_job_age = (int) ( $data['age'] ?? 0 );

            // Update production heartbeat to prevent watchdog from killing the runner.
            if ($this->production_id > 0 && class_exists('Costudio_Production')) {
                Costudio_Production::update_heartbeat($this->production_id);
            }

            if ($poll_count % 4 === 0) {
                costudio_log_debug(
                    'contentos.local_polling',
                    [
                        'job_id'  => $job_id,
                        'elapsed' => time() - $start,
                        'status'  => $status,
                    ]
                );
                $this->log_job_event('local_polling', [
                    'job_id'        => $job_id,
                    'production_id' => $this->production_id,
                    'polls'         => $poll_count,
                    'job_age'       => $last_job_age,
                    'elapsed'       => time() - $start,
                    'status'        => $status,
                ]);
            }

            if ($status === 'completed' || $status === 'failed') {
                return $response;
            }
        }

        $this->log_job_event('local_timeout', [
            'job_id'        => $job_id,
            'production_id' => $this->production_id,
            'polls'         => $poll_count,
            'elapsed'       => time() - $start,
            'last_status'   => $last_status,
            'last_job_age'  => $last_job_age,
            'timeout'       => $max_seconds,
        ], 'warn');

        return new WP_REST_Response([
            'message' => sprintf(
                /* translators: 1: last job status, 2: job age in seconds. */
                __('Local job polling timed out (last status: %1$s, job age: %2$d seconds).', 'contentosapp-studio'),
                $last_status,
                $last_job_age
            ),
        ], 504);
    }

    // ── Remote call (async: submit → poll) ───────────────────────────────────

    private function call_remote_proxy(string $endpoint, array $body, array $headers, int $timeout)
    {
        $headers['X-FOS-Async'] = '1';
        $req_headers            = $this->merge_remote_browser_headers($headers);

        // Resume a chunked poll left pending by a previous (shorter-lived)
        // request instead of submitting a brand-new job. See
        // Costudio_Generation_Deferred_Exception.
        $pending = $this->get_pending_job();
        if (null !== $pending) {
            return $this->poll_for_result($pending['job_id'], $pending['poll_token'], $timeout, $pending);
        }

        $submit = $this->remote_submit_generic($endpoint, $body, $req_headers);

        if (is_wp_error($submit)) {
            costudio_log('contentos.api_submit_failed', [
                'code'  => $submit->get_error_code(),
                'error' => $submit->get_error_message(),
            ], 'warning');
			if ( ! empty( $body['attempt_id'] ) ) {
				Costudio_Remote_Jobs::mark_transport_wait( (string) $body['attempt_id'] );
				throw new Costudio_Generation_Deferred_Exception();
			}
			return $submit;
        }

        $submit_code = (int) wp_remote_retrieve_response_code($submit);
        $submit_raw  = (string) wp_remote_retrieve_body($submit);

        costudio_log_debug('contentos.submit_response', [
            'http'        => $submit_code,
            'body_length' => strlen($submit_raw),
			'json'        => is_array( json_decode( $submit_raw, true ) ),
        ]);

        if ($submit_code === 200) {
            return $submit;
        }

        if ($submit_code !== 202) {
			if ( $submit_code >= 500 && ! empty( $body['attempt_id'] ) ) {
				Costudio_Remote_Jobs::mark_transport_wait( (string) $body['attempt_id'] );
				throw new Costudio_Generation_Deferred_Exception();
			}
            return $submit;
        }

        $submit_body = json_decode($submit_raw, true);
        if (empty($submit_body['job_id'])) {
            return $submit;
        }

        $job_id     = (string) $submit_body['job_id'];
        $poll_token = (string) ($submit_body['poll_token'] ?? '');
        if ('' === $poll_token) {
            return new WP_Error('contentos_missing_poll_token', 'ContentOS API accepted the job without returning polling credentials.');
        }
		$queue_version = (int) ( $submit_body['queue_version'] ?? 1 );
		$attempt_id    = (string) ( $submit_body['attempt_id'] ?? ( $body['attempt_id'] ?? '' ) );
		if ( 2 === $queue_version && '' !== $attempt_id ) {
			Costudio_Remote_Jobs::bind( $attempt_id, $job_id, $poll_token, $queue_version );
		} elseif ( '' !== $attempt_id ) {
			// Server-side rollout is off for this license. Keep using the v1
			// transient and close the provisional durable attempt locally.
			Costudio_Remote_Jobs::observe( $attempt_id, 'superseded' );
		}

        $fresh_state = [
            'job_id'          => $job_id,
            'poll_token'      => $poll_token,
            'consecutive_5xx' => 0,
            'sleep_seconds'   => self::POLL_INTERVAL,
            'last_status'     => 'unknown',
            'first_seen_at'   => time(),
			'attempt_id'      => $attempt_id,
			'queue_version'   => $queue_version,
        ];
        $this->save_pending_job($fresh_state);

        $this->log_job_event('submitted', [
            'job_id'        => $job_id,
            'production_id' => $this->production_id,
            'timeout'       => $timeout,
        ]);

        return $this->poll_for_result(
            $job_id,
            $poll_token,
            $timeout,
            $fresh_state
        );
    }

    /**
     * Tenta primeiro admin-ajax (muitos hosts bloqueiam só /wp-json), depois REST.
     *
     * @return array|WP_Error Resposta wp_remote_*.
     */
    private function remote_submit_generic(string $endpoint, array $body, array $req_headers)
    {
        $ua = self::REMOTE_BROWSER_UA;

        // Map endpoint to AJAX action.
        $action = 'cap_api_generate';
        if (strpos($endpoint, '/ai/agent/generate') !== false) {
            $action = 'cap_api_agent_generate';
        }

        $ajax = wp_remote_post(costudio_api_ajax_url($action), [
            'timeout'    => self::SUBMIT_TIMEOUT,
            'sslverify'  => costudio_sslverify( 'contentos_adapter' ),
            'headers'    => $req_headers,
            'body'       => wp_json_encode($body),
            'user-agent' => $ua,
        ]);

        if (! is_wp_error($ajax)) {
            $raw = (string) wp_remote_retrieve_body($ajax);
            if ($this->looks_like_json_api_body($raw)) {
                return $ajax;
            }
        }

        return wp_remote_post($endpoint, [
            'timeout'    => self::SUBMIT_TIMEOUT,
            'sslverify'  => costudio_sslverify( 'contentos_adapter' ),
            'headers'    => $req_headers,
            'body'       => wp_json_encode($body),
            'user-agent' => $ua,
        ]);
    }

    private function looks_like_json_api_body(string $raw): bool
    {
        $trim = ltrim($raw);
        if ($trim === '' || ($trim[0] !== '{' && $trim[0] !== '[')) {
            return false;
        }
        return is_array(json_decode($raw, true));
    }

    /**
     * Cabeçalhos extras para reduzir bloqueio por WAF em rotas públicas.
     *
     * @param array<string,string> $headers
     * @return array<string,string>
     */
    private function merge_remote_browser_headers(array $headers): array
    {
        $base = 'https://' . self::API_HOST;

        return array_merge(
            [
                'Accept'          => 'application/json, */*;q=0.8',
                'Accept-Language' => 'en-US,en;q=0.9,pt-BR;q=0.8,pt;q=0.7',
                'Origin'          => $base,
                'Referer'         => $base . '/wp-admin/',
                'X-Requested-With' => 'XMLHttpRequest',
            ],
            $headers
        );
    }

    /**
     * Poll using only the job-specific secret. The original submit credentials
     * and signature must not be reused for this GET request.
     *
     * @param array{consecutive_5xx?:int,sleep_seconds?:int,last_status?:string,first_seen_at?:int} $resume_state
     *        State carried over from a previous chunk (see get_pending_job()/
     *        save_pending_job()). Pass the freshly-built default state on a
     *        first submission, or the loaded transient when resuming.
     */
    private function poll_for_result(string $job_id, string $poll_token, int $max_seconds, array $resume_state = [])
    {
        $ua             = self::REMOTE_BROWSER_UA;
        $poll_url_ajax  = add_query_arg('job_id', $job_id, costudio_api_ajax_url('cap_api_job_status'));
        $poll_url_rest  = 'https://' . self::API_HOST . '/wp-json/contentosapp-api/v1/ai/job/' . rawurlencode($job_id);

        $poll_headers = $this->merge_remote_browser_headers([
            'Accept'           => 'application/json',
            'X-FOS-Poll-Token' => $poll_token,
        ]);

        $start          = time();
        // Chunk boundary: never let this single call block longer than this,
        // regardless of $max_seconds — see Costudio_Generation_Deferred_Exception.
        $chunk_deadline = $start + min(self::POLL_CHUNK_BUDGET, $max_seconds);
        $first_seen_at  = (int) ($resume_state['first_seen_at'] ?? $start);

        $poll_failures       = 0;
        $poll_count          = 0;
        // Resume backoff/streak state across chunks so the overall 5xx
        // tolerance below is unchanged by chunking — it just spans multiple
        // short requests instead of one long one.
        $consecutive_5xx     = (int) ($resume_state['consecutive_5xx'] ?? 0);
        $last_status         = (string) ($resume_state['last_status'] ?? 'unknown');
        $last_job_age        = 0;
        // Tightened from 8 → 4: persistent 5xx means the origin or its CDN is
        // unhealthy and continuing to hammer it both wastes the production's
        // time budget and trips Cloudflare's WAF (we observed a 403 "Attention
        // Required" challenge after ~7 consecutive 502 polls in production).
        $max_consecutive_5xx = (int) apply_filters('costudio_contentos_max_5xx_polls', 4);
        // Once the server has confirmed the job alive ("processing"), a 5xx
        // streak usually means the origin is momentarily out of PHP workers
        // while the long AI call holds one (observed on Writer/PILLAR runs:
        // 502s starting ~2min in, three identical reproductions) — not that
        // it is down. The job keeps running and billing server-side, so
        // abandoning it early wastes the whole attempt. With the capped
        // backoff below, 8 polls ≈ up to ~5 extra minutes of tolerance.
        $max_5xx_busy = (int) apply_filters('costudio_contentos_max_5xx_polls_busy', max(8, $max_consecutive_5xx));
        $sleep_seconds       = (int) ($resume_state['sleep_seconds'] ?? self::POLL_INTERVAL);

        while ((time() - $start) < $max_seconds) {
            if (time() >= $chunk_deadline) {
                // Yield back to the pipeline instead of sleeping/polling further
                // in this same request. Costudio_Runner re-dispatches this exact
                // agent step in a fresh, short-lived background request, which
                // resumes polling this same job_id via get_pending_job() above.
                $this->save_pending_job([
                    'job_id'          => $job_id,
                    'poll_token'      => $poll_token,
                    'consecutive_5xx' => $consecutive_5xx,
                    'sleep_seconds'   => $sleep_seconds,
                    'last_status'     => $last_status,
                    'first_seen_at'   => $first_seen_at,
					'attempt_id'      => (string) ( $resume_state['attempt_id'] ?? '' ),
					'queue_version'   => (int) ( $resume_state['queue_version'] ?? 1 ),
                ]);
                $this->log_job_event('chunk_yield', [
                    'job_id'        => $job_id,
                    'production_id' => $this->production_id,
                    'polls'         => $poll_count,
                    'elapsed_total' => time() - $first_seen_at,
                    'last_status'   => $last_status,
                ]);
                throw new Costudio_Generation_Deferred_Exception();
            }

            sleep($sleep_seconds);

            $poll = wp_remote_get($poll_url_ajax, [
                'timeout'    => self::SUBMIT_TIMEOUT,
                'sslverify'  => costudio_sslverify( 'contentos_adapter' ),
                'headers'    => $poll_headers,
                'user-agent' => $ua,
            ]);

            if (! is_wp_error($poll)) {
                $pr = (string) wp_remote_retrieve_body($poll);
                if (! $this->looks_like_json_api_body($pr)) {
                    $poll = wp_remote_get($poll_url_rest, [
                        'timeout'    => self::SUBMIT_TIMEOUT,
                        'sslverify'  => costudio_sslverify( 'contentos_adapter' ),
                        'headers'    => $poll_headers,
                        'user-agent' => $ua,
                    ]);
                }
            }

            if (is_wp_error($poll)) {
                if (++$poll_failures >= 5) {
                    $this->clear_pending_job();
					if ( 2 === (int) ( $resume_state['queue_version'] ?? 0 ) ) {
						throw new Costudio_Generation_Deferred_Exception();
					}
                    return $poll;
                }
                continue;
            }

            $poll_failures = 0;
            $code     = (int) wp_remote_retrieve_response_code($poll);
            $poll_raw = (string) wp_remote_retrieve_body($poll);

            costudio_log_debug('contentos.poll_response', [
                'http'        => $code,
                'body_length' => strlen($poll_raw),
				'status'      => is_array( json_decode( $poll_raw, true ) ) ? (string) ( json_decode( $poll_raw, true )['status'] ?? '' ) : '',
            ]);

            $data = json_decode($poll_raw, true);
            $has_json_body = is_array($data);

            if ($has_json_body && ($data['status'] ?? '') === 'failed') {
				$this->finish_pending_job( (string) ( $data['terminal_state'] ?? 'failed' ) );
                return $this->build_job_failed_response($data, $code);
            }

            // ── WAF challenge bail-out ──────────────────────────────────────
            // After repeated 5xx polls, Cloudflare often promotes the IP to a
            // managed challenge (HTTP 403 with the "Attention Required" HTML
            // page). Continuing to poll only deepens the penalty. Detect this
            // shape early and abort with a dedicated error so the user sees a
            // useful message instead of a generic timeout.
            if ($code === 403 && (stripos($poll_raw, 'cloudflare') !== false || stripos($poll_raw, 'attention required') !== false)) {
                $this->clear_pending_job();
				if ( 2 === (int) ( $resume_state['queue_version'] ?? 0 ) ) {
					throw new Costudio_Generation_Deferred_Exception();
				}
                return new WP_Error(
                    'contentos_waf_blocked',
                    'ContentOS API was blocked by Cloudflare WAF (HTTP 403 with managed challenge). The origin appears overloaded and Cloudflare is rate-limiting this IP. Wait a few minutes before retrying, or switch to a BYOK provider (Gemini / Claude / OpenAI) in Settings → AI.'
                );
            }

            // Circuit breaker: persistent 5xx means the backend (or its CDN)
            // is dead. Cloudflare answers 502/503/504 with a JSON body that
            // would otherwise pass the looks_like_json check and trap the loop
            // until $max_seconds expires. We also apply a capped exponential
            // backoff (15s → 22s → 33s → 50s) so we don't spam an unhealthy
            // origin and trigger its WAF defenses.
            if ($code >= 500 && $code < 600) {
                $consecutive_5xx++;
                // Job already seen alive → busy-origin tolerance; never seen
                // healthy → origin likely down, keep the fast breaker.
                $limit_5xx = ('processing' === $last_status) ? $max_5xx_busy : $max_consecutive_5xx;
                if ($consecutive_5xx >= $limit_5xx) {
                    $this->clear_pending_job();
					if ( 2 === (int) ( $resume_state['queue_version'] ?? 0 ) ) {
						throw new Costudio_Generation_Deferred_Exception();
					}
                    return new WP_Error(
                        'contentos_backend_unreachable',
                        sprintf(
                            'ContentOS backend is unreachable (HTTP %d after %d consecutive polls). The origin (contentosapp.com) appears overloaded or down. Try again in a few minutes, or switch to a BYOK provider (Gemini / Claude / OpenAI) in Settings → AI.',
                            $code,
                            $consecutive_5xx
                        )
                    );
                }
                // Exponential backoff (×1.5 per failure), capped at 60s.
                $sleep_seconds = (int) min(60, ceil($sleep_seconds * 1.5));
                continue;
            }

            // Other 4xx (401/403/404) on the poll endpoint indicate a hard
            // failure that won't recover by waiting — bail immediately.
            if ($code >= 400 && $code < 500) {
                $this->clear_pending_job();
				if ( 2 === (int) ( $resume_state['queue_version'] ?? 0 ) ) {
					// Queue V2 bindings are durable. A proxy/WAF/auth-layer response is
					// not an authoritative terminal from the job record, so preserve the
					// attempt and let the next runner chunk poll it again.
					throw new Costudio_Generation_Deferred_Exception();
				}
                return new WP_Error(
                    'contentos_poll_4xx',
                    sprintf('ContentOS poll endpoint returned HTTP %d (non-recoverable).', $code)
                );
            }

            // Healthy poll — reset both 5xx counter and sleep cadence.
            $consecutive_5xx = 0;
            $sleep_seconds   = self::POLL_INTERVAL;
            $poll_count++;

            if (! $has_json_body) {
                continue;
            }

            $status  = $data['status'] ?? '';
            $job_age = (int) ($data['age'] ?? 0);
            $last_status  = (string) $status;
            $last_job_age = $job_age;

            // Update production heartbeat to prevent watchdog from killing the runner.
            if ($this->production_id > 0 && class_exists('Costudio_Production')) {
                Costudio_Production::update_heartbeat($this->production_id);
            }

            // Progress logging every ~60s so long polls are visible in error_log.
            if ($poll_count % 4 === 0) {
                costudio_log_debug(
                    'contentos.remote_polling',
                    [
                        'polls'   => $poll_count,
                        'job_age' => $job_age,
                        'elapsed' => time() - $start,
                        'status'  => $status,
                    ]
                );
                $this->log_job_event('polling', [
                    'job_id'        => $job_id,
                    'production_id' => $this->production_id,
                    'polls'         => $poll_count,
                    'job_age'       => $job_age,
                    'elapsed'       => time() - $start,
                    'status'        => $status,
                ]);
            }

            if ($status === 'completed') {
                $output  = $data['output'] ?? '';
                $payload = ['output' => $output];
                // Forward server-side grounded sources (Phase 4 fallback) so
                // parse_response() captures them onto $this->last_sources for
                // the pipeline to optionally persist into sources_json.
                if (! empty($data['sources']) && is_array($data['sources'])) {
                    $payload['sources'] = array_values($data['sources']);
                }
                costudio_log_debug(
                    'contentos.job_completed',
                    [
                        'output_length' => strlen($output),
                        'polls'         => $poll_count,
                        'sources'       => isset($payload['sources']) ? count($payload['sources']) : 0,
                    ]
                );
                $this->log_job_event('completed', [
                    'job_id'        => $job_id,
                    'production_id' => $this->production_id,
                    'polls'         => $poll_count,
                    'elapsed'       => time() - $start,
                    'output_length' => strlen($output),
                ]);
				$this->finish_pending_job( 'completed' );
                return $this->build_http_response(200, $payload);
            }

            if ($status === 'failed') {
				$this->finish_pending_job( (string) ( $data['terminal_state'] ?? 'failed' ) );
                return $this->build_job_failed_response($data, $code);
            }

            // Stale job detection: bail out if server reports excessive age.
			if ( 2 !== (int) ( $resume_state['queue_version'] ?? 0 ) && $status === 'processing' && $job_age > self::MAX_JOB_AGE) {
                costudio_log_debug(
                    'contentos.job_stale',
                    [
                        'age'   => $job_age,
                        'max'   => self::MAX_JOB_AGE,
                        'polls' => $poll_count,
                    ]
                );
                $this->log_job_event('stale', [
                    'job_id'        => $job_id,
                    'production_id' => $this->production_id,
                    'polls'         => $poll_count,
                    'job_age'       => $job_age,
                ], 'warn');
                $this->clear_pending_job();
                return new WP_Error(
                    'contentos_job_stale',
                    sprintf(
                        'AI generation has been running for %d seconds without result. The AI provider may be overloaded or the server worker crashed. Try again, or switch to a direct provider (Gemini / Claude / OpenAI) in Settings → AI.',
                        $job_age
                    )
                );
            }
        }

        $this->log_job_event('timeout', [
            'job_id'        => $job_id,
            'production_id' => $this->production_id,
            'polls'         => $poll_count,
            'elapsed'       => time() - $start,
            'last_status'   => $last_status,
            'last_job_age'  => $last_job_age,
            'timeout'       => $max_seconds,
        ], 'warn');

        $this->clear_pending_job();
        return new WP_Error(
            'contentos_job_timeout',
            sprintf(
                /* translators: 1: timeout in seconds, 2: last job status, 3: job age in seconds. */
                __('AI generation job did not complete within %1$d seconds (last status: %2$s, job age: %3$d seconds).', 'contentosapp-studio'),
                $max_seconds,
                $last_status,
                $last_job_age
            )
        );
    }

    private function log_job_event(string $event, array $context, string $level = 'info'): void
    {
        if (! class_exists('Costudio_Logger')) {
            return;
        }

        $production_id = ! empty($context['production_id']) ? (int) $context['production_id'] : null;
        unset($context['production_id']);

        $message = 'ContentOS job ' . $event;
        if ('warn' === $level) {
            Costudio_Logger::warn($message, $context, $production_id);
            return;
        }

        Costudio_Logger::info($message, $context, $production_id);
    }

    private function build_job_failed_response(array $data, int $http_code): array
    {
        $fail_code = (string) ($data['error']['code'] ?? 'gemini_error');
        $fail_msg  = (string) ($data['error']['message'] ?? 'Job failed on server.');
        $mapped_http = isset($data['http_status']) ? (int) $data['http_status'] : $http_code;

        if ($mapped_http < 400 || $mapped_http > 599) {
            $mapped_http = 500;
        }

        costudio_log('contentos.job_failed_by_server', [
            'code' => $fail_code,
            'msg'  => $fail_msg,
            'http' => $mapped_http,
        ], 'warning');

        $error = [
            'message' => $fail_msg,
            'code'    => $fail_code,
        ];

        if (! empty($data['service_scope']) && is_scalar($data['service_scope'])) {
            $error['service_scope'] = (string) $data['service_scope'];
        }

        return $this->build_http_response($mapped_http, [
            'error' => $error,
        ]);
    }

    private function build_http_response(int $code, array $data): array
    {
        return [
            'response' => ['code' => $code, 'message' => ''],
            'body'     => wp_json_encode($data),
            'headers'  => [],
            'cookies'  => [],
        ];
    }

}
