<?php
/**
 * Costudio_Admin_Ajax_Production — production lifecycle AJAX endpoints.
 *
 * Handles all endpoints related to article production: fetching remote WP
 * content, starting/polling pipelines, approving/resuming/retrying agents,
 * publishing drafts, editing outputs, exporting (zip/csv), link discovery,
 * article preview, and sync of externally published posts.
 *
 * Extracted from Costudio_Admin as part of Task 2 (PR 5 — Production AJAX split).
 *
 * @package Contentosapp_Studio
 * @since   3.2.0
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Admin_Ajax_Production extends Costudio_Admin_Ajax_Base {

	/** @var self|null */
	private static ?self $instance = null;

	private function __construct() {}

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

	public function register_hooks(): void {
		$this->register_ajax_actions(
			array(
				// Fetch content
				'costudio_fetch_wp_post',
				'costudio_fetch_post_content',
				// Production lifecycle
				'costudio_start_production',
				'costudio_poll_status',
				'costudio_approve_agent',
				'costudio_resume_pipeline',
				'costudio_retry_agent',
				'costudio_publish',
				'costudio_get_production',
				'costudio_pause_production',
				'costudio_cancel_production',
				'costudio_delete_production',
				'costudio_update_output',
				'costudio_export_production',
				'costudio_export_csv',
				'costudio_preview_links',
				'costudio_preview_article',
				'costudio_suggest_production_field',
				// Remote post sync
				'costudio_sync_posts',
			)
		);
	}

	/**
	 * @return int[]
	 */
	private function post_int_array( array $value ): array {
		$items = array();
		foreach ( $value as $item ) {
			if ( is_scalar( $item ) ) {
				$items[] = absint( $item );
			}
		}

		return array_values( array_filter( $items ) );
	}

	/**
	 * @return string[]
	 */
	private function post_text_array( array $value ): array {
		$items = array();
		foreach ( $value as $item ) {
			if ( is_scalar( $item ) ) {
				$items[] = sanitize_text_field( (string) $item );
			}
		}

		return array_values( array_filter( $items ) );
	}

	/**
	 * Build the exact, sanitized payload accepted by the production lifecycle.
	 *
	 * @return array<string,mixed>
	 */
	private function build_start_production_request(): array {
		$in = $this->read_request(
			array(
				'site_key'            => 'key',
				'plan_id'             => 'int',
				'nicho'               => 'text',
				'categoria'           => 'text',
				'keyword'             => 'text',
				'angulo'              => 'text',
				'perfil'              => 'text',
				'article_type'        => 'key',
				'extensao'            => 'text',
				'observacoes'         => 'textarea',
				'publish_mode'        => 'key',
				'publish_date'        => 'text',
				'execution_mode'      => 'key',
				'lang_code'           => 'text',
				'pilar_url'           => 'url',
				'pilar_title'         => 'text',
				'comparison_criteria' => 'text',
				'comparison_items'    => 'array',
				'news_sources'        => 'array',
				'news_author_id'      => 'int',
				'update_post_id'      => 'int',
				'update_post_url'     => 'url',
				'update_post_title'   => 'text',
				'update_post_content' => 'block',
				'update_instructions' => 'textarea',
				'notas_adicionais'    => 'textarea',
				'links_internos'      => 'textarea',
			)
		);

		return array(
			'site_key'            => $in['site_key'],
			'plan_id'             => $in['plan_id'],
			'nicho'               => $in['nicho'],
			'categoria'           => $in['categoria'],
			'keyword'             => $in['keyword'],
			'angulo'              => $in['angulo'],
			'perfil'              => $in['perfil'],
			'article_type'        => $in['article_type'],
			'extensao'            => $in['extensao'] ?: '2000-2600',
			'observacoes'         => $in['observacoes'],
			'publish_mode'        => $in['publish_mode'] ?: 'draft',
			'publish_date'        => $in['publish_date'],
			'execution_mode'      => $in['execution_mode'] ?: 'auto',
			'lang_code'           => $in['lang_code'],
			'pilar_url'           => $in['pilar_url'],
			'pilar_title'         => $in['pilar_title'],
			'comparison_criteria' => $in['comparison_criteria'],
			'comparison_items'    => $this->sanitize_comparison_items( $in['comparison_items'] ),
			'news_sources'        => $this->sanitize_news_sources( $in['news_sources'] ),
			'news_author_id'      => $in['news_author_id'],
			'update_post_id'      => $in['update_post_id'],
			'update_post_url'     => $in['update_post_url'],
			'update_post_title'   => $in['update_post_title'],
			'update_post_content' => $in['update_post_content'],
			'update_instructions' => $in['update_instructions'],
			'notas_adicionais'    => $in['notas_adicionais'],
			'links_internos'      => $this->post_links_json( $in['links_internos'] ),
		);
	}

	/**
	 * @param array<int,array<string,mixed>> $items
	 * @return array<int,array{label:string,url:string}>
	 */
	private function sanitize_comparison_items( array $items ): array {
		$clean = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$label = sanitize_text_field( (string) ( $item['label'] ?? '' ) );
			$url   = esc_url_raw( (string) ( $item['url'] ?? '' ) );
			if ( '' === $label ) {
				continue;
			}

			$clean[] = array(
				'label' => $label,
				'url'   => $url,
			);
		}

		return $clean;
	}

	/**
	 * @param array<int,array<string,mixed>> $sources
	 * @return array<int,array{url:string}>
	 */
	private function sanitize_news_sources( array $sources ): array {
		$clean = array();

		foreach ( $sources as $source ) {
			if ( ! is_array( $source ) ) {
				continue;
			}

			$url = esc_url_raw( (string) ( $source['url'] ?? '' ) );
			if ( '' === $url ) {
				continue;
			}

			$clean[] = array( 'url' => $url );
		}

		return $clean;
	}

	private function post_links_json( string $payload ): string {
		if ( '' === trim( $payload ) ) {
			return '';
		}

		$decoded = json_decode( $payload, true );
		if ( ! is_array( $decoded ) ) {
			return '';
		}

		$clean = array(
			'internal' => $this->sanitize_internal_links_payload( $decoded['internal'] ?? array() ),
			'external' => $this->sanitize_external_links_payload( $decoded['external'] ?? array() ),
		);

		$encoded = wp_json_encode( $clean );
		return is_string( $encoded ) ? $encoded : '';
	}

	private function sanitize_internal_links_payload( $items ): array {
		$items = is_array( $items ) ? array_slice( $items, 0, 100 ) : array();
		$clean = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$url = esc_url_raw( (string) ( $item['url'] ?? '' ) );
			if ( '' === $url ) {
				continue;
			}

			$post_type = sanitize_key( (string) ( $item['post_type'] ?? 'post' ) );
			$clean[]   = array(
				'title'     => sanitize_text_field( (string) ( $item['title'] ?? '' ) ),
				'url'       => $url,
				'site_key'  => sanitize_key( (string) ( $item['site_key'] ?? '' ) ),
				'site_name' => sanitize_text_field( (string) ( $item['site_name'] ?? '' ) ),
				'post_type' => in_array( $post_type, array( 'post', 'page' ), true ) ? $post_type : 'post',
				'selected'  => true,
			);
		}

		return $clean;
	}

	private function sanitize_external_links_payload( $items ): array {
		$items = is_array( $items ) ? array_slice( $items, 0, 50 ) : array();
		$clean = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$url = esc_url_raw( (string) ( $item['url'] ?? '' ) );
			if ( '' === $url ) {
				continue;
			}

			$clean[] = array(
				'url'         => $url,
				'description' => sanitize_textarea_field( (string) ( $item['description'] ?? '' ) ),
			);
		}

		return $clean;
	}

	// ── AJAX: fetch content ───────────────────────────────────────────────────

	public function ajax_costudio_fetch_wp_post(): void {
		$in = $this->read_request(
			array(
				'site_key' => 'text',
				'query'    => 'text',
			)
		);

		$site_key = $in['site_key'];
		$query    = $in['query'];

		if ( empty( $site_key ) || empty( $query ) ) {
			wp_send_json_error( __( 'Missing parameters.', 'contentosapp-studio' ) );
		}

		$sites_repo = costudio_sites();
		$site_cfg   = $sites_repo ? $sites_repo->get( $site_key ) : null;
		if ( ! $site_cfg ) {
			wp_send_json_error( __( 'Site not found.', 'contentosapp-studio' ) );
		}
		if ( $sites_repo && ! $sites_repo->is_site_operable( $site_key ) ) {
			wp_send_json_error(
				empty( $site_cfg['active'] )
					? __( 'Site is inactive (or not configured).', 'contentosapp-studio' )
					: $sites_repo->get_unavailable_site_message()
			);
		}

		$base_url = costudio_safe_http_url( rtrim( (string) $site_cfg['url'], '/' ) );
		if ( '' === $base_url ) {
			wp_send_json_error( __( 'Invalid or unsafe site URL.', 'contentosapp-studio' ) );
		}
		$user     = $site_cfg['user'] ?? '';
		$password = Costudio_Sites_Crypto::decrypt( $site_cfg['password'] ?? '' );
		$headers  = array( 'Authorization' => 'Basic ' . base64_encode( "{$user}:{$password}" ) );

		$is_url = filter_var( $query, FILTER_VALIDATE_URL );
		if ( $is_url ) {
			$url = $base_url . '/wp-json/wp/v2/posts?_fields=id,title,link,date,modified,content&link=' . urlencode( $query );
			$res = wp_safe_remote_get( $url, costudio_safe_remote_args( array( 'timeout' => 15, 'headers' => $headers ) ) );

			if ( is_wp_error( $res ) ) {
				wp_send_json_error( $res->get_error_message() );
			}

			$code = (int) wp_remote_retrieve_response_code( $res );
			$body = json_decode( wp_remote_retrieve_body( $res ), true );

			if ( 200 !== $code ) {
				wp_send_json_error( $body['message'] ?? "HTTP {$code}" );
			}

			if ( empty( $body ) ) {
				// Fallback to pages
				$url = $base_url . '/wp-json/wp/v2/pages?_fields=id,title,link,date,modified,content&link=' . urlencode( $query );
				$res = wp_safe_remote_get( $url, costudio_safe_remote_args( array( 'timeout' => 15, 'headers' => $headers ) ) );
				if ( ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ) ) {
					$body = json_decode( wp_remote_retrieve_body( $res ), true );
				}
			}
		} else {
			$url = $base_url . '/wp-json/wp/v2/posts?_fields=id,title,link,date,modified,content&search=' . urlencode( $query ) . '&per_page=5';
			$res = wp_safe_remote_get( $url, costudio_safe_remote_args( array( 'timeout' => 15, 'headers' => $headers ) ) );

			if ( is_wp_error( $res ) ) {
				wp_send_json_error( $res->get_error_message() );
			}

			$code = (int) wp_remote_retrieve_response_code( $res );
			$body = json_decode( wp_remote_retrieve_body( $res ), true );

			if ( 200 !== $code ) {
				wp_send_json_error( $body['message'] ?? "HTTP {$code}" );
			}
		}

		if ( ! is_array( $body ) ) {
			wp_send_json_error( __( 'Invalid response from remote site.', 'contentosapp-studio' ) );
		}

		$posts = array();
		foreach ( $body as $post ) {
			$content_raw = $post['content']['rendered'] ?? '';
			$posts[]     = array(
				'id'         => $post['id'] ?? 0,
				'title'      => sanitize_text_field( html_entity_decode( $post['title']['rendered'] ?? '', ENT_QUOTES, 'UTF-8' ) ),
				'url'        => esc_url_raw( $post['link'] ?? '' ),
				'date'       => sanitize_text_field( $post['date'] ?? '' ),
				'modified'   => sanitize_text_field( $post['modified'] ?? '' ),
				'word_count' => str_word_count( wp_strip_all_tags( $content_raw ) ),
			);
		}

		wp_send_json_success( array( 'posts' => $posts ) );
	}

	public function ajax_costudio_fetch_post_content(): void {
		$in = $this->read_request(
			array(
				'site_key' => 'text',
				'post_id'  => 'int',
			)
		);

		$site_key = $in['site_key'];
		$post_id  = $in['post_id'];

		if ( empty( $site_key ) || ! $post_id ) {
			wp_send_json_error( __( 'Missing parameters.', 'contentosapp-studio' ) );
		}

		$sites_repo = costudio_sites();
		$site_cfg   = $sites_repo ? $sites_repo->get( $site_key ) : null;
		if ( ! $site_cfg ) {
			wp_send_json_error( __( 'Site not found.', 'contentosapp-studio' ) );
		}
		if ( $sites_repo && ! $sites_repo->is_site_operable( $site_key ) ) {
			wp_send_json_error(
				empty( $site_cfg['active'] )
					? __( 'Site is inactive (or not configured).', 'contentosapp-studio' )
					: $sites_repo->get_unavailable_site_message()
			);
		}

		// Local site shortcut: bypass the REST round-trip and read the post
		// straight from the database. The REST path tries context=edit first
		// and silently degrades to context=view when the Application Password
		// user lacks edit_posts — context=view strips Gutenberg block
		// delimiters (<!-- wp:... -->), which then makes parse_blocks() return
		// a single freeform block in the Sectioner and the Update flow ends
		// up overwriting the whole article with one edited section. Reading
		// from the DB always returns the raw post_content with block
		// delimiters intact, regardless of capabilities.
		if ( Costudio_WP_Publisher::LOCAL_SITE_KEY === $site_key ) {
			$post = get_post( $post_id );
			if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
				wp_send_json_error( __( 'Post not found on this site.', 'contentosapp-studio' ) );
			}

			$content_raw = (string) $post->post_content;
			$title_raw   = (string) $post->post_title;

			$max_content_chars = 40000;
			$content_truncated = false;
			if ( mb_strlen( $content_raw ) > $max_content_chars ) {
				$content_raw       = mb_substr( $content_raw, 0, $max_content_chars );
				$content_truncated = true;
			}

			// Block delimiters live as HTML comments. If they are missing,
			// the post was authored in the Classic editor and parse_blocks()
			// will return one freeform block — the Sectioner guards against
			// that downstream, but we surface the signal here for telemetry.
			$has_raw_blocks = false !== strpos( $content_raw, '<!-- wp:' );

			wp_send_json_success(
				array(
					'id'                => (int) $post->ID,
					'title'             => sanitize_text_field( html_entity_decode( $title_raw, ENT_QUOTES, 'UTF-8' ) ),
					'url'               => esc_url_raw( get_permalink( $post ) ?: '' ),
					'content'           => $content_raw,
					'date'              => sanitize_text_field( (string) $post->post_date_gmt ),
					'modified'          => sanitize_text_field( (string) $post->post_modified_gmt ),
					'word_count'        => str_word_count( wp_strip_all_tags( $content_raw ) ),
					'content_truncated' => $content_truncated,
					'has_raw_blocks'    => $has_raw_blocks,
					'used_edit_context' => true,
					'source'            => 'local_db',
				)
			);
		}

		$base_url = costudio_safe_http_url( rtrim( (string) $site_cfg['url'], '/' ) );
		if ( '' === $base_url ) {
			wp_send_json_error( __( 'Invalid or unsafe site URL.', 'contentosapp-studio' ) );
		}
		$user     = $site_cfg['user'] ?? '';
		$password = Costudio_Sites_Crypto::decrypt( $site_cfg['password'] ?? '' );
		$headers  = array( 'Authorization' => 'Basic ' . base64_encode( "{$user}:{$password}" ) );

		// REMOTE-SITE PATH STARTS HERE. The local-site shortcut above already
		// returned via wp_send_json_success when $site_key === LOCAL_SITE_KEY.
		// Update flow needs raw Gutenberg blocks (not the_content-rendered HTML)
		// so the editor agent can preserve images, links and custom-HTML blocks
		// verbatim. context=edit requires the Application Password user to have
		// edit_posts capability. If the request is forbidden, we retry without
		// it and degrade to content.rendered — the editor agent still works
		// against HTML, just with fewer guarantees on custom blocks.
		$fetch_post = function ( string $base, string $post_type, bool $edit_context ) use ( $post_id, $headers ) {
			$ctx_query = $edit_context ? 'context=edit&' : '';
			$url       = $base . '/wp-json/wp/v2/' . $post_type . '/' . $post_id . '?' . $ctx_query . '_fields=id,title,content,link,date,modified';
			return wp_safe_remote_get( $url, costudio_safe_remote_args( array( 'timeout' => 15, 'headers' => $headers ) ) );
		};

		$res         = $fetch_post( $base_url, 'posts', true );
		$used_edit   = true;
		$res_code    = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );

		// Pages fallback (only when the post lookup hard-failed at the transport layer).
		if ( is_wp_error( $res ) ) {
			$res      = $fetch_post( $base_url, 'pages', true );
			$res_code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
		}

		// context=edit forbidden / unavailable → retry without it on posts (then pages).
		if ( ! is_wp_error( $res ) && in_array( $res_code, array( 401, 403 ), true ) ) {
			$used_edit = false;
			$res       = $fetch_post( $base_url, 'posts', false );
			$res_code  = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
			if ( ! is_wp_error( $res ) && 200 !== $res_code ) {
				$res      = $fetch_post( $base_url, 'pages', false );
				$res_code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
			}
		}

		if ( is_wp_error( $res ) ) {
			wp_send_json_error( $res->get_error_message() );
		}

		$body = json_decode( wp_remote_retrieve_body( $res ), true );

		if ( 200 !== $res_code ) {
			wp_send_json_error( is_array( $body ) && ! empty( $body['message'] ) ? $body['message'] : "HTTP {$res_code}" );
		}

		// Prefer raw (Gutenberg block markup, includes <!-- wp:... --> delimiters).
		// Fall back to rendered HTML only as a last resort.
		$content_raw = $body['content']['raw'] ?? ( $body['content']['rendered'] ?? '' );
		$title_raw   = $body['title']['raw']   ?? ( $body['title']['rendered']   ?? '' );

		// Hard validation: the Update flow consumes raw Gutenberg block markup.
		// Three ways the content can arrive unusable:
		//   1. context=edit was rejected (401/403) → REST returned only
		//      content.rendered, which has no <!-- wp:... --> delimiters.
		//   2. context=edit succeeded but the post was authored in the
		//      Classic editor and has no block markup to begin with.
		//   3. The site stripped block delimiters via a misconfigured filter.
		// In every case, downstream parse_blocks() would collapse the whole
		// article into a single freeform block and the Update would
		// overwrite the live post with a partial edit. Reject up front with
		// a precise message so the user can fix the root cause.
		$has_raw_blocks = false !== strpos( $content_raw, '<!-- wp:' );

		if ( ! $has_raw_blocks ) {
			if ( ! $used_edit ) {
				wp_send_json_error(
					__( 'The Application Password user on this site is missing the edit_posts capability, so WordPress only returned rendered HTML (no Gutenberg block markup). The Update flow cannot run without raw blocks. Open Settings → Sites, update the credentials to an Editor or Administrator user with an Application Password, and try again.', 'contentosapp-studio' )
				);
			}

			wp_send_json_error(
				__( 'This post has no Gutenberg block markup (probably authored in the Classic editor). The Update flow can only edit posts that use the block editor. Open the post in WordPress, re-save it with the block editor to convert legacy content, and try again.', 'contentosapp-studio' )
			);
		}

		// Cap size to keep prompt context manageable. Gutenberg comments inflate
		// the payload vs. the previous stripped-text approach, so the ceiling is
		// higher than before.
		$max_content_chars = 40000;
		$content_truncated = false;
		if ( mb_strlen( $content_raw ) > $max_content_chars ) {
			$content_raw       = mb_substr( $content_raw, 0, $max_content_chars );
			$content_truncated = true;
		}

		$result = array(
			'id'                => $body['id'] ?? 0,
			'title'             => sanitize_text_field( html_entity_decode( $title_raw, ENT_QUOTES, 'UTF-8' ) ),
			'url'               => esc_url_raw( $body['link'] ?? '' ),
			'content'           => $content_raw,
			'date'              => sanitize_text_field( $body['date'] ?? '' ),
			'modified'          => sanitize_text_field( $body['modified'] ?? '' ),
			'word_count'        => str_word_count( wp_strip_all_tags( $content_raw ) ),
			'content_truncated' => $content_truncated,
			'has_raw_blocks'    => $has_raw_blocks,
			'used_edit_context' => $used_edit,
			'source'            => 'remote_rest',
		);

		wp_send_json_success( $result );
	}

	// ── AJAX: lightweight AI suggestions for production fields ───────────────

	public function ajax_costudio_suggest_production_field(): void {
		$in = $this->read_request(
			array(
				'field_type'     => 'key',
				'keyword'        => 'text',
				'site_key'       => 'text',
				'category'       => 'text',
				'article_type'   => 'text',
				'angle'          => 'text',
				'reader_profile' => 'text',
				'lang_code'      => 'text',
			)
		);

		$field_type = $in['field_type'];
		$keyword    = $in['keyword'];

		if ( ! in_array( $field_type, array( 'angle', 'reader_profile' ), true ) ) {
			wp_send_json_error( __( 'Invalid option.', 'contentosapp-studio' ), 400 );
		}

		if ( mb_strlen( trim( $keyword ) ) < 4 ) {
			wp_send_json_error( __( 'Keyword is required.', 'contentosapp-studio' ), 400 );
		}

		$lang_code = trim( $in['lang_code'] );
		if ( '' === $lang_code ) {
			$lang_code = Costudio_Admin::get_default_content_language();
		} elseif ( ! Costudio_Admin::is_supported_content_language( $lang_code ) ) {
			wp_send_json_error( __( 'Invalid content language.', 'contentosapp-studio' ), 400 );
		}

		$context   = $this->get_production_field_suggestion_context(
			$field_type,
			$keyword,
			$in['site_key'],
			$in['category'],
			$in['article_type'],
			$in['angle'],
			$in['reader_profile'],
			$lang_code
		);
		$cache_key = $this->get_production_field_suggestion_cache_key( $context );
		$cached    = get_transient( $cache_key );

		if ( is_array( $cached ) && ! empty( $cached['suggestion'] ) ) {
			wp_send_json_success(
				array(
					'suggestion' => (string) $cached['suggestion'],
					'source'     => 'ai',
					'cached'     => true,
				)
			);
		}

		if ( ! $this->allow_production_field_suggestion_request() ) {
			wp_send_json_error( __( 'Too many attempts. Please wait a few minutes and try again.', 'contentosapp-studio' ), 429 );
		}

		try {
			$client     = $this->make_production_field_suggestion_client( $context );
			$prompt     = $this->build_production_field_suggestion_prompt( $context );
			$output     = $client->generate( $prompt );
			$suggestion = $this->parse_production_field_suggestion( $output, $field_type );

			if ( '' === $suggestion ) {
				wp_send_json_error( __( 'Could not generate field suggestion right now.', 'contentosapp-studio' ), 502 );
			}

			set_transient(
				$cache_key,
				array(
					'suggestion' => $suggestion,
					'created_at'  => time(),
				),
				12 * HOUR_IN_SECONDS
			);

			wp_send_json_success(
				array(
					'suggestion' => $suggestion,
					'source'     => 'ai',
					'cached'     => false,
				)
			);
		} catch ( Throwable $e ) {
			if ( function_exists( 'costudio_log' ) ) {
				costudio_log( 'production.field_suggestion.failed', array( 'field_type' => $field_type, 'error' => $e->getMessage() ), 'warning' );
			}

			wp_send_json_error( __( 'Could not generate field suggestion right now.', 'contentosapp-studio' ), 502 );
		}
	}

	/**
	 * @return array<string,string>
	 */
	private function get_production_field_suggestion_context(
		string $field_type,
		string $keyword,
		string $site_key,
		string $category,
		string $article_type,
		string $angle,
		string $reader_profile,
		string $lang_code
	): array {
		$settings = function_exists( 'costudio_settings' ) ? costudio_settings() : null;
		$provider = $settings ? (string) $settings->get( 'ai_provider', 'gemini' ) : 'gemini';
		$model    = $settings ? (string) $settings->get( 'ai_model', '' ) : '';
		$ai_mode  = $settings ? (string) $settings->get( 'ai_mode', 'contentos' ) : 'contentos';
		$locale   = Costudio_Admin::is_supported_content_language( $lang_code )
			? $lang_code
			: Costudio_Admin::get_default_content_language();
		$site     = '';

		if ( '' !== $site_key && function_exists( 'costudio_sites' ) ) {
			$repo = costudio_sites();
			$cfg  = $repo ? $repo->get( $site_key ) : null;
			if ( is_array( $cfg ) ) {
				$site = function_exists( 'costudio_site_display_name' )
					? costudio_site_display_name( $cfg, $site_key )
					: (string) ( $cfg['name'] ?? $site_key );
			}
		}

		return array(
			'field_type'     => $field_type,
			'keyword'        => trim( $keyword ),
			'site_key'       => $site_key,
			'site'           => trim( $site ),
			'category'       => trim( $category ),
			'article_type'   => trim( $article_type ),
			'angle'          => trim( $angle ),
			'reader_profile' => trim( $reader_profile ),
			'locale'         => $locale,
			'provider'       => $provider,
			'model'          => $model,
			'ai_mode'        => $ai_mode,
		);
	}

	/**
	 * @param array<string,string> $context
	 */
	private function get_production_field_suggestion_cache_key( array $context ): string {
		$payload = array(
			'v'              => 1,
			'field_type'     => $context['field_type'] ?? '',
			'keyword'        => mb_strtolower( $context['keyword'] ?? '' ),
			'site_key'       => $context['site_key'] ?? '',
			'category'       => mb_strtolower( $context['category'] ?? '' ),
			'article_type'   => $context['article_type'] ?? '',
			'angle'          => mb_strtolower( $context['angle'] ?? '' ),
			'reader_profile' => mb_strtolower( $context['reader_profile'] ?? '' ),
			'locale'         => $context['locale'] ?? '',
			'provider'       => $context['provider'] ?? '',
			'model'          => $context['model'] ?? '',
			'ai_mode'        => $context['ai_mode'] ?? '',
		);

		return 'costudio_prod_field_suggest_' . md5( wp_json_encode( $payload ) );
	}

	private function allow_production_field_suggestion_request(): bool {
		$user_id = get_current_user_id();
		$key     = 'costudio_prod_field_suggest_rl_' . ( $user_id > 0 ? $user_id : 'anon' );
		$count   = (int) get_transient( $key );

		if ( $count >= 20 ) {
			return false;
		}

		set_transient( $key, $count + 1, 10 * MINUTE_IN_SECONDS );
		return true;
	}

	/**
	 * @param array<string,string> $context
	 */
	private function make_production_field_suggestion_client( array $context ): Costudio_AI_Client {
		$settings = function_exists( 'costudio_settings' ) ? costudio_settings() : null;
		$provider = $context['provider'] ?: 'gemini';
		$api_key  = $settings ? (string) $settings->get( 'ai_api_key', '' ) : '';
		$ai_mode  = $context['ai_mode'] ?? '';

		$use_contentos = ( 'contentos' === $ai_mode ) || ( '' === $api_key && 'byok' !== $ai_mode );
		if ( $use_contentos ) {
			$license_key = (string) get_option( 'costudio_license_key', '' );
			return new Costudio_ContentOS_Adapter( $license_key, $provider );
		}

		$model_defaults = array(
			'gemini' => 'gemini-2.5-flash',
			'openai' => 'gpt-4o',
			'claude' => 'claude-haiku-4-5-20251001',
		);
		$model = $context['model'] ?: ( $model_defaults[ $provider ] ?? 'gemini-2.5-flash' );

		if ( class_exists( 'Costudio_Rate_Limiter' ) ) {
			Costudio_Rate_Limiter::wait( $provider, $api_key );
		}

		switch ( $provider ) {
			case 'claude':
				$client = new Costudio_Claude_Adapter( $api_key, $model );
				break;
			case 'openai':
				$client = new Costudio_OpenAI_Adapter( $api_key, $model );
				break;
			default:
				$client = new Costudio_Gemini_Adapter( $api_key, $model, false );
				break;
		}

		if ( method_exists( $client, 'set_thinking_budget' ) ) {
			$client->set_thinking_budget( 0 );
		}

		return $client;
	}

	/**
	 * @param array<string,string> $context
	 */
	private function build_production_field_suggestion_prompt( array $context ): string {
		$site_line     = $context['site'] ? '- Publication site: ' . $context['site'] . "\n" : '';
		$category_line = $context['category'] ? '- Category: ' . $context['category'] . "\n" : '';
		$type_line     = $context['article_type'] ? '- Article type: ' . $context['article_type'] . "\n" : '';
		$angle_line    = $context['angle'] && 'reader_profile' === $context['field_type'] ? '- Existing editorial angle: ' . $context['angle'] . "\n" : '';
		$reader_line   = $context['reader_profile'] && 'angle' === $context['field_type'] ? '- Existing reader profile: ' . $context['reader_profile'] . "\n" : '';

		if ( 'reader_profile' === $context['field_type'] ) {
			$task = "Generate one specific reader profile for this article.\nThe profile must describe who the article is for, their intent, experience level, and main concern.\nDo not write a title.";
			$max  = 160;
		} else {
			$task = "Generate one strong editorial angle for this article.\nThe angle should be suitable as the article H1/title direction.\nDo not write a reader persona.";
			$max  = 120;
		}

		return sprintf(
			"%s\n\nContext:\n- Language/locale: %s\n- Main keyword: \"%s\"\n%s%s%s%s%s\nRules:\n- Return only valid JSON.\n- Return format: {\"suggestion\":\"...\"}\n- Do not include markdown, numbering, explanations, or extra keys.\n- Avoid clickbait and exaggerated promises.\n- Keep it natural, specific, and useful for a content production brief.\n- Keep the suggestion under %d characters.\n",
			$task,
			$context['locale'] ?: 'pt_BR',
			$context['keyword'],
			$site_line,
			$category_line,
			$type_line,
			$angle_line,
			$reader_line,
			$max
		);
	}

	private function parse_production_field_suggestion( string $output, string $field_type ): string {
		$text = trim( $output );
		$text = preg_replace( '/^```(?:json)?\s*/i', '', $text );
		$text = preg_replace( '/\s*```$/', '', (string) $text );

		$decoded = json_decode( $text, true );
		if ( null === $decoded ) {
			$start = strpos( $text, '{' );
			$end   = strrpos( $text, '}' );
			if ( false !== $start && false !== $end && $end > $start ) {
				$decoded = json_decode( substr( $text, $start, $end - $start + 1 ), true );
			}
		}

		if ( is_array( $decoded ) ) {
			$text = (string) ( $decoded['suggestion'] ?? reset( $decoded ) );
		}

		$suggestion = sanitize_text_field( (string) $text );
		$suggestion = preg_replace( '/^\s*(?:[-*]|\d+[.)])\s*/', '', (string) $suggestion );
		$suggestion = trim( html_entity_decode( (string) $suggestion, ENT_QUOTES, 'UTF-8' ) );
		$suggestion = trim( $suggestion, " \t\n\r\0\x0B\"'" );

		if ( mb_strlen( $suggestion ) < 8 ) {
			return '';
		}

		$max = 'reader_profile' === $field_type ? 180 : 140;
		if ( mb_strlen( $suggestion ) > $max ) {
			$suggestion = rtrim( mb_substr( $suggestion, 0, $max ), " \t\n\r\0\x0B.,;:-" );
		}

		return $suggestion;
	}

	// ── AJAX: production lifecycle ────────────────────────────────────────────

	public function ajax_costudio_start_production(): void {
		// Nonce + capability are verified inside build_start_production_request()
		// (it reads the request via read_request() in one scope).
		$result = Costudio_Admin_Production_Lifecycle::start_production( $this->build_start_production_request() );
		if ( is_wp_error( $result ) ) {
			$data = $result->get_error_data();
			wp_send_json_error( is_array( $data ) ? $data : $result->get_error_message() );
		}

		wp_send_json_success( $result );
	}

	public function ajax_costudio_poll_status(): void {
		$in            = $this->read_request( array( 'production_id' => 'int' ) );
		$production_id = $in['production_id'];
		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		$production = Costudio_Production::find( $production_id );
		if ( ! $production ) {
			wp_send_json_error( __( 'Not found.', 'contentosapp-studio' ) );
		}

		// Polling never executes publication code; it only nudges the durable
		// dispatcher when a queued/expired job needs a new bounded worker.
		if ( class_exists( 'Costudio_Publication_Jobs' ) && Costudio_Publication_Jobs::has_live_job( $production_id ) ) {
			Costudio_Publication_Jobs::ensure_progress( $production_id, 'admin_poll' );
		}
		if ( class_exists( 'Costudio_Runner' ) ) {
			Costudio_Runner::ensure_handoff_progress( $production_id, 'admin_poll' );
		}

		if ( $this->maybe_recover_stale_production( $production ) ) {
			$production = Costudio_Production::find( $production_id );
			if ( ! $production ) {
				wp_send_json_error( __( 'Not found.', 'contentosapp-studio' ) );
			}
		}

		// Collect completed agents without sending full outputs on every poll.
		// Full output remains available through costudio_get_production when the
		// user opens the "View/Edit output" modal.
		$agents_done     = array();
		$agent_previews  = array();
		$preview_chars   = 600;
		for ( $i = 1; $i <= 7; $i++ ) {
			$col = sprintf( 'output_%02d', $i );
			if ( ! empty( $production->$col ) ) {
				$output_text          = (string) $production->$col;
				$agents_done[ $i ]    = true;
				$agent_previews[ $i ] = mb_substr( $output_text, 0, $preview_chars );
			}
		}

		// Current step for UI: use DB current_agent, else infer from outputs (pending + no outputs must still show agent 1).
		$current_agent = (int) ( $production->current_agent ?? 0 );
		if ( 'error' !== $production->status && 'completed' !== $production->status ) {
			if ( $current_agent < 1 ) {
				for ( $i = 7; $i >= 1; $i-- ) {
					$col = sprintf( 'output_%02d', $i );
					if ( ! empty( $production->$col ) ) {
						$current_agent = min( $i + 1, 7 );
						break;
					}
				}
			}
			if ( $current_agent < 1 ) {
				$current_agent = 1;
			}
		}

		// Include partial streaming output when an agent is currently generating.
		$stream_partial = null;
		if ( 'running' === $production->status && class_exists( 'Costudio_Pipeline' ) ) {
			$stream_partial = Costudio_Pipeline::get_stream_partial( $production_id );
		}

		// Calculate durations and word counts for the response
		$agent_durations   = [];
		$agent_start_times = [];
		$word_counts       = [];
		$usage_rows        = Costudio_Usage::get_production_latency_rows( $production_id );

		foreach ( $usage_rows as $row ) {
			$usage_agent = (int) $row->agent;
			$output_col  = $production->map_step_to_column( $usage_agent );
			if ( $usage_agent < 1 || ! $output_col || empty( $production->$output_col ) ) {
				// A reprocessed agent has no current output yet. Keep its older
				// usage row for telemetry, but never show it as this attempt.
				continue;
			}

			$ms = (int) $row->latency_ms;
			$s  = (int) round( $ms / 1000 );
			
			if ( $s >= 60 ) {
				$agent_durations[ $row->agent ] = sprintf( '%dm %ds', floor( $s / 60 ), $s % 60 );
			} else {
				$agent_durations[ $row->agent ] = sprintf( '%ds', $s );
			}

			// v1.2.9+ rows carry the stable attempt boundary. Historical rows
			// retain the previous created_at - latency fallback.
			$start_ts = ! empty( $row->started_at )
				? strtotime( $row->started_at . ' UTC' )
				: strtotime( $row->created_at . ' UTC' ) - ( $ms / 1000 );
			$agent_start_times[ $row->agent ] = wp_date( 'H:i:s', $start_ts ) . ' ' . wp_timezone_string();
		}

		for ( $i = 1; $i <= 7; $i++ ) {
			$col = sprintf( 'output_%02d', $i );
			$txt = $production->$col ?? '';
			if ( $txt ) {
				$word_counts[ $i ] = Costudio_Content_Word_Count::format_editorial_words( $txt );
			}
		}

		// Real wall-clock session time, including local queue and pauses.
		$elapsed_total_s = $production->get_elapsed_seconds();
		$duration_lbl    = $elapsed_total_s > 0
			? ( $elapsed_total_s >= 60
				? sprintf( '%dm %ds', floor( $elapsed_total_s / 60 ), $elapsed_total_s % 60 )
				: sprintf( '%ds', $elapsed_total_s ) )
			: '—';

		$version   = $production->get_pipeline_version();
		$agent_qty = ( $version === 7 ) ? 7 : 6;
		$agents_done_count = count( $agents_done );
		$is_finalizing = (
			'running' === $production->status
			&& $agents_done_count >= $agent_qty
		);

		$pipeline_phase = 'running' === $production->status ? 'agent' : (string) $production->status;
		$phase_label    = '';
		$phase_subtitle = '';
		$queue_position = 0;
		$queue_total    = 0;

		if ( $is_finalizing ) {
			$pipeline_phase = 'publishing';
			$phase_label    = __( 'Finalizando publicação', 'contentosapp-studio' );
			$phase_subtitle = __( 'Todos os agentes terminaram. O Contentosapp Studio está preparando referências, imagens, metadados e o rascunho no WordPress.', 'contentosapp-studio' );
		} elseif ( 'running' === $production->status ) {
			$phase_label    = __( 'Processando agente', 'contentosapp-studio' );
			$phase_subtitle = __( 'O Contentosapp Studio está gerando a saída do agente atual.', 'contentosapp-studio' );
		} elseif ( 'pending' === $production->status ) {
			$queue_total    = Costudio_Production::count( array( 'status' => 'pending' ) );
			$queue_position = class_exists( 'Costudio_Production_Queue' )
				? Costudio_Production_Queue::queue_position( $production_id )
				: 0;
		}

		// The runner claims this boundary once per attempt. Chunk-yield logs are
		// observability only and must never redefine the timer.
		$current_agent_start_ts = (
			! $is_finalizing
			&& $current_agent > 0
			&& $current_agent <= $agent_qty
			&& ! empty( $production->current_agent_started_at )
		)
			? (int) strtotime( $production->current_agent_started_at . ' UTC' )
			: 0;
		$current_agent_elapsed_s = $current_agent_start_ts > 0
			? $production->get_current_agent_elapsed_seconds()
			: 0;

		wp_send_json_success( array(
			'status'                 => $production->status,
			'agents_done'            => $agents_done,
			'agent_previews'         => $agent_previews,
			'agent_durations'        => $agent_durations,
			'agent_start_times'      => $agent_start_times,
			'word_counts'            => $word_counts,
			'current_agent'          => $current_agent,
			'current_agent_start_ts' => $current_agent_start_ts,
			'current_agent_elapsed_s'=> $current_agent_elapsed_s,
			'elapsed_total_s'        => $elapsed_total_s,
			'total_processing_s'     => $production->get_total_processing_seconds(),
			'agent_qty'              => $agent_qty,
			'pipeline_phase'         => $pipeline_phase,
			'phase_label'            => $phase_label,
			'phase_subtitle'         => $phase_subtitle,
			'agent_defs'      => Costudio_Pipeline::get_agent_definitions( $version ),
			'duration_lbl'    => $duration_lbl,
			'edit_url'        => $production->edit_url    ?? '',
			'post_url'        => $production->wp_post_url ?? '',
			'error_message'   => $production->error_message ?? '',
			'failed_agent'    => $production->error_agent   ?? 0,
			'stream_partial'  => $stream_partial,
			'pipeline_log'    => Costudio_Runner::get_pipeline_log( $production_id ),
			'pipeline_diag'   => Costudio_Runner::get_pipeline_diag( $production_id ),
			'queue_position'  => $queue_position,
			'queue_total'     => $queue_total,
			'started_at_ts'   => $production->started_at ? strtotime( $production->started_at . ' UTC' ) : 0,
			'server_time'     => time(),
		) );
	}

	/**
	 * Stale-recovery piggybacked on poll_status.
	 *
	 * The cron watchdog handles this in the background, but poll_status gets the
	 * same protection while the user is watching the production detail page.
	 * We mark the pipeline as a retryable error instead of silently
	 * re-dispatching so the same agent is not duplicated behind the user's back.
	 */
	private function maybe_recover_stale_production( $row ): bool {
		if ( 'running' !== $row->status || empty( $row->heartbeat ) ) {
			return false;
		}
		// An empty attempt boundary means the selected agent has not started.
		// That state is safe to re-dispatch and is handled by
		// ensure_handoff_progress(); it must not be classified as an ambiguous
		// provider crash by the stale watchdog.
		if ( empty( $row->current_agent_started_at ) ) {
			return false;
		}
		if ( class_exists( 'Costudio_Remote_Jobs' ) && Costudio_Remote_Jobs::has_live_job( (int) $row->id ) ) {
			return false;
		}
		if ( class_exists( 'Costudio_Publication_Jobs' ) && Costudio_Publication_Jobs::has_live_job( (int) $row->id ) ) {
			Costudio_Publication_Jobs::ensure_progress( (int) $row->id, 'admin_poll_stale' );
			return false;
		}
		if ( ! class_exists( 'Costudio_Housekeeping' ) ) {
			return false;
		}

		$beat  = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $row->heartbeat, new \DateTimeZone( 'UTC' ) );
		if ( ! $beat ) {
			return false;
		}

		$elapsed = time() - $beat->getTimestamp();
		$threshold = method_exists( 'Costudio_Housekeeping', 'stalled_after_seconds_for_production' )
			? Costudio_Housekeeping::stalled_after_seconds_for_production( $row )
			: Costudio_Housekeeping::stalled_after_seconds();

		if ( $elapsed <= $threshold ) {
			return false;
		}

		return Costudio_Housekeeping::mark_stalled_production( (int) $row->id, 'poll_status' );
	}

	public function ajax_costudio_approve_agent(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		$production = Costudio_Production::find( $production_id );
		if ( ! $production ) {
			wp_send_json_error( __( 'Production not found.', 'contentosapp-studio' ) );
		}

		if ( ! in_array( $production->status, [ 'paused', 'awaiting_approval' ], true ) ) {
			wp_send_json_error( __( 'Production is not in a state that requires approval.', 'contentosapp-studio' ) );
		}

		$next_agent = max( 1, (int) ( $production->current_agent ?? 1 ) );

		if ( class_exists( 'Costudio_Runner' ) ) {
			Costudio_Runner::pipeline_log_append(
				$production_id,
				sprintf(
					/* translators: %d: next agent number. */
					__( 'Revisão manual aprovada. Produção aguardando slot local para o Agente %d…', 'contentosapp-studio' ),
					$next_agent
				),
				'ok',
				$next_agent
			);
		}

		Costudio_Production::mark_pending( $production_id, $next_agent );

		if ( class_exists( 'Costudio_Production_Queue' ) ) {
			$result = Costudio_Production_Queue::submit( $production_id );
			wp_send_json_success( $result );
		}

		Costudio_Production::mark_pipeline_started( $production_id, $next_agent );

		if ( class_exists( 'Costudio_Runner' ) ) {
			$secret_token = wp_generate_password( 32, false );
			Costudio_Runner::prime_background_token( $production_id, $secret_token, 'ajax_approve_agent' );
			Costudio_Runner::kick_off_background( $production_id, $secret_token );
		}

		wp_send_json_success( array( 'queued' => false, 'status' => 'running', 'position' => 0 ) );
	}

	public function ajax_costudio_resume_pipeline(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		if ( ! class_exists( 'Costudio_Runner' ) ) {
			wp_send_json_error( __( 'Pipeline runner is not available.', 'contentosapp-studio' ) );
		}

		$result = Costudio_Runner::resume_pipeline_manual( $production_id );
		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( is_array( $result ) ? $result : array( 'queued' => false, 'status' => 'running', 'position' => 0 ) );
	}

	public function ajax_costudio_retry_agent(): void {
		$in = $this->read_request(
			array(
				'production_id' => 'int',
				'from_agent'    => 'int',
			)
		);

		$production_id = $in['production_id'];
		$from_agent    = $in['from_agent'] ?: 1;

		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		Costudio_Production::reset_for_retry( $production_id, $from_agent, 'pending' );

		if ( class_exists( 'Costudio_Production_Queue' ) ) {
			$result = Costudio_Production_Queue::submit( $production_id );
			wp_send_json_success( array( 'from_agent' => $from_agent, 'queue' => $result ) );
		}

		Costudio_Production::mark_pipeline_started( $production_id, $from_agent );

		$secret_token = wp_generate_password( 32, false );
		Costudio_Runner::prime_background_token( $production_id, $secret_token, 'ajax_retry_agent' );
		Costudio_Runner::kick_off_background( $production_id, $secret_token );

		wp_send_json_success( array( 'from_agent' => $from_agent ) );
	}

	public function ajax_costudio_publish(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		$production    = Costudio_Production::find( $production_id );

		if ( ! $production ) {
			wp_send_json_error( __( 'Production not found.', 'contentosapp-studio' ) );
		}

		$site_key   = (string) ( $production->site_key ?? '' );
		$sites_repo = costudio_sites();
		$site_cfg   = $sites_repo ? $sites_repo->get( $site_key ) : null;
		if ( ! $site_cfg || empty( $site_cfg['active'] ) ) {
			wp_send_json_error( __(
				'This site is inactive (or not configured). Activate it in ContentOS → Settings → Sites before publishing.',
				'contentosapp-studio'
			) );
		}
		// Idempotency guard: if already published, return existing data.
		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 ?? '')
				);
			}
			wp_send_json_success( array(
				'post_id'           => (int) $production->wp_post_id,
				'post_url'          => $production->wp_post_url ?? '',
				'edit_url'          => $production->edit_url ?? '',
				'title'             => $production->title ?? '',
				'already_published' => true,
			) );
		}

		$inputs = json_decode( $production->inputs ?? '{}', true );

		try {
			$publisher = new Costudio_WP_Publisher( $production->site_key );
			$result    = $publisher->publish( array(
				'output_02' => $production->output_02 ?? '',
				'output_04' => $production->output_04 ?? '',
				'output_05' => $production->output_05 ?? '',
				'inputs'    => $inputs,
				'site_key'  => $production->site_key,
				'production_id' => $production_id,
			) );

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

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

			wp_send_json_success( $result );
		} catch ( Exception $e ) {
			if ( ! ( $e instanceof Costudio_Publication_Busy_Exception ) ) {
				Costudio_Production::set_error( $production_id, $e->getMessage() );
			}
			wp_send_json_error( $e->getMessage() );
		}
	}

	public function ajax_costudio_get_production(): void {
		$in         = $this->read_request( array( 'production_id' => 'int' ) );
		$id         = $in['production_id'];
		$production = Costudio_Production::find( $id );

		if ( ! $production ) {
			wp_send_json_error( __( 'Not found.', 'contentosapp-studio' ) );
		}

		wp_send_json_success( (array) $production );
	}

	public function ajax_costudio_delete_production(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$id = $in['production_id'];
		Costudio_Production::delete( $id );
		$queue = class_exists( 'Costudio_Production_Queue' )
			? Costudio_Production_Queue::maybe_dispatch_next( 'ajax_delete_production' )
			: null;
		wp_send_json_success( array( 'queue' => $queue ) );
	}

	public function ajax_costudio_pause_production(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		$production = Costudio_Production::find( $production_id );
		if ( ! $production ) {
			wp_send_json_error( __( 'Production not found.', 'contentosapp-studio' ) );
		}

		Costudio_Production::set_status( $production_id, 'paused' );

		if ( class_exists( 'Costudio_Runner' ) ) {
			Costudio_Runner::pipeline_log_append( $production_id, __( 'Pausa solicitada pelo utilizador.', 'contentosapp-studio' ), 'warn' );
		}

		$queue = class_exists( 'Costudio_Production_Queue' )
			? Costudio_Production_Queue::maybe_dispatch_next( 'ajax_pause_production' )
			: null;

		wp_send_json_success( array( 'queue' => $queue ) );
	}

	public function ajax_costudio_cancel_production(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		if ( ! $production_id ) {
			wp_send_json_error( __( 'Invalid ID.', 'contentosapp-studio' ) );
		}

		$production = Costudio_Production::find( $production_id );
		if ( ! $production ) {
			wp_send_json_error( __( 'Production not found.', 'contentosapp-studio' ) );
		}

		if ( class_exists( 'Costudio_Remote_Jobs' ) ) {
			Costudio_Remote_Jobs::request_cancel( $production_id );
		}
		if ( class_exists( 'Costudio_BYOK_Jobs' ) ) {
			Costudio_BYOK_Jobs::request_cancel_for_production( $production_id, 'user_cancel' );
		}
		if ( class_exists( 'Costudio_Publication_Jobs' ) ) {
			Costudio_Publication_Jobs::cancel_for_production( $production_id, 'user_cancel' );
		}
		Costudio_Production::set_status( $production_id, 'cancelled' );

		if ( class_exists( 'Costudio_Runner' ) ) {
			Costudio_Runner::pipeline_log_append( $production_id, __( 'Produção cancelada pelo utilizador.', 'contentosapp-studio' ), 'warn' );
		}

		$queue = class_exists( 'Costudio_Production_Queue' )
			? Costudio_Production_Queue::maybe_dispatch_next( 'ajax_cancel_production' )
			: null;

		wp_send_json_success( array( 'queue' => $queue ) );
	}

	public function ajax_costudio_update_output(): void {
		$in = $this->read_request(
			array(
				'production_id' => 'int',
				'agent'         => 'int',
				'content'       => 'html',
				'updated_at'    => 'text',
			)
		);

		$production_id = $in['production_id'];
		$agent         = $in['agent'];
		$content       = $in['content'];
		$expected_at   = $in['updated_at'];

		if ( ! $production_id || $agent < 1 || $agent > 6 ) {
			wp_send_json_error( __( 'Invalid parameters.', 'contentosapp-studio' ) );
		}

		// Optimistic lock: if the client sends updated_at, verify no concurrent edit.
		if ( ! empty( $expected_at ) ) {
			$ok = Costudio_Production::update_output_with_lock( $production_id, $agent, $content, $expected_at );
			if ( ! $ok ) {
				wp_send_json_error(
					__( 'This content was modified by another user or process. Please reload and try again.', 'contentosapp-studio' ),
					409
				);
			}
			$production = Costudio_Production::find( $production_id );
			wp_send_json_success( array(
				'agent'      => $agent,
				'saved'      => true,
				'updated_at' => $production->updated_at ?? '',
			) );
		}

		// Legacy path: no lock (backwards-compatible with existing JS).
		Costudio_Production::update_output( $production_id, $agent, $content );
		$production = Costudio_Production::find( $production_id );
		wp_send_json_success( array(
			'agent'      => $agent,
			'saved'      => true,
			'updated_at' => $production->updated_at ?? '',
		) );
	}

	public function ajax_costudio_export_production(): void {
		$in         = $this->read_request( array( 'production_id' => 'int' ) );
		$id         = $in['production_id'];
		$production = Costudio_Production::find( $id );

		if ( ! $production ) {
			wp_send_json_error( __( 'Not found.', 'contentosapp-studio' ) );
		}

		$result = Costudio_Admin_Production_Exporter::export_production_zip( $production );
		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( $result );
	}

	public function ajax_costudio_export_csv(): void {
		$in = $this->read_request(
			array(
				'tab' => 'key',
				'ids' => 'array',
			)
		);

		$tab_raw = $in['tab'];
		$tab = in_array( $tab_raw, array( 'planning', 'history' ), true )
			? $tab_raw
			: 'history';

		// Optional: specific IDs to export (empty = export all).
		$ids = $this->post_int_array( $in['ids'] );

		wp_send_json_success( Costudio_Admin_Production_Exporter::export_csv( $tab, $ids ) );
	}

	public function ajax_costudio_preview_links(): void {
		$in = $this->read_request(
			array(
				'site_keys'  => 'array',
				'site_key'   => 'text',
				'post_types' => 'array',
				'keywords'   => 'text',
			)
		);

		// Accept site_keys (array) or legacy site_key (string).
		$site_keys = $this->post_text_array( $in['site_keys'] );
		if ( empty( $site_keys ) && '' !== $in['site_key'] ) {
			$site_keys = array( $in['site_key'] );
		}

		// Accept post_types (array), default to ['post'] to preserve existing behaviour.
		$allowed_types  = array( 'post', 'page' );
		$post_types_raw = $this->post_text_array( $in['post_types'] );
		$post_types     = array_values( array_filter(
			$post_types_raw,
			fn( $t ) => in_array( $t, $allowed_types, true )
		) );
		if ( empty( $post_types ) ) {
			$post_types = array( 'post' );
		}

		$keywords = $in['keywords'];

		if ( empty( $site_keys ) || empty( $keywords ) ) {
			wp_send_json_error( __( 'Site or keywords not provided.', 'contentosapp-studio' ) );
		}

		try {
			$kws     = array_filter( array_map( 'trim', explode( ',', $keywords ) ) );
			$finder  = new Costudio_Link_Finder();
			$results = $finder->find( $kws, $site_keys, $post_types );
			wp_send_json_success( array( 'links' => $results ) );
		} catch ( Exception $e ) {
			wp_send_json_error( $e->getMessage() );
		}
	}

	// ── AJAX: article preview ────────────────────────────────────────────────

	/**
	 * Return a rendered HTML preview of the finished article (no publish, no images).
	 */
	public function ajax_costudio_preview_article(): void {
		$in = $this->read_request( array( 'production_id' => 'int' ) );

		$production_id = $in['production_id'];
		if ( $production_id <= 0 ) {
			wp_send_json_error( __( 'Invalid production ID.', 'contentosapp-studio' ) );
		}

		$production = Costudio_Production::find( $production_id );
		if ( ! $production ) {
			wp_send_json_error( __( 'Production not found.', 'contentosapp-studio' ) );
		}

		$output_02 = $production->output_02 ?? '';
		$output_04 = $production->output_04 ?? '';
		$output_05 = $production->output_05 ?? '';

		if ( empty( $output_04 ) ) {
			wp_send_json_error( __( 'Article content (Agent 04) is not available yet.', 'contentosapp-studio' ) );
		}

		$preview = Costudio_WP_Publisher::preview( array(
			'output_02' => $output_02,
			'output_04' => $output_04,
			'output_05' => $output_05,
		) );

		wp_send_json_success( $preview );
	}

	// ── AJAX: sync remote posts ───────────────────────────────────────────────

	public function ajax_costudio_sync_posts(): void {
		$in = $this->read_request( array( 'site_key' => 'text' ) );

		$site_key   = $in['site_key'];
		$sites_repo = costudio_sites();
		$sites      = $sites_repo ? $sites_repo->get_all() : array();
		$to_sync    = $site_key && isset( $sites[ $site_key ] )
			? array( $site_key => $sites[ $site_key ] )
			: $sites;

		if ( empty( $to_sync ) ) {
			wp_send_json_error( __( 'No sites configured.', 'contentosapp-studio' ) );
		}

		$total_imported = 0;
		$errors         = array();

		foreach ( $to_sync as $key => $cfg ) {
			if ( $sites_repo && ! $sites_repo->is_site_operable( (string) $key ) ) {
				$message = empty( $cfg['active'] )
					? __( 'Site is inactive (or not configured).', 'contentosapp-studio' )
					: $sites_repo->get_unavailable_site_message();

				if ( $site_key ) {
					wp_send_json_error( $message );
				}

				$errors[] = costudio_site_display_name( $cfg, (string) $key ) . ': ' . $message;
				continue;
			}

			try {
				$total_imported += $this->sync_site_posts( $key, $cfg );
			} catch ( Exception $e ) {
				$errors[] = costudio_site_display_name( $cfg, $key ) . ': ' . $e->getMessage();
			}
		}

		wp_send_json_success( array( 'imported' => $total_imported, 'errors' => $errors ) );
	}

	// ── Helpers ───────────────────────────────────────────────────────────────

	private function sync_site_posts( string $site_key, array $cfg ): int {
		return Costudio_Admin_Remote_Post_Sync::sync_site_posts( $site_key, $cfg );
	}
}
