<?php
/**
 * Pro AI provider abstraction (BYOK).
 *
 * Three providers ship today: OpenAI Chat Completions, Google Gemini generateContent
 * and Groq (OpenAI-compatible) Chat Completions. Each task builds an instruction
 * prompt expecting strict JSON back; the dispatcher caches the parsed response per
 * content hash and enforces a per-user daily quota. All endpoints are Pro-gated.
 *
 * @package Newsroom_AI_Assistant
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! defined( 'NEWSAI_AI_DEFAULT_OPENAI_MODEL' ) ) {
	define( 'NEWSAI_AI_DEFAULT_OPENAI_MODEL', 'gpt-4o-mini' );
}
if ( ! defined( 'NEWSAI_AI_DEFAULT_GEMINI_MODEL' ) ) {
	define( 'NEWSAI_AI_DEFAULT_GEMINI_MODEL', 'gemini-2.5-flash' );
}
if ( ! defined( 'NEWSAI_AI_DEFAULT_GROQ_MODEL' ) ) {
	define( 'NEWSAI_AI_DEFAULT_GROQ_MODEL', 'llama-3.3-70b-versatile' );
}
if ( ! defined( 'NEWSAI_AI_CACHE_TTL' ) ) {
	define( 'NEWSAI_AI_CACHE_TTL', 30 * MINUTE_IN_SECONDS );
}
if ( ! defined( 'NEWSAI_AI_DAILY_DEFAULT_LIMIT' ) ) {
	define( 'NEWSAI_AI_DAILY_DEFAULT_LIMIT', 50 );
}
if ( ! defined( 'NEWSAI_AI_MAX_INPUT_CHARS' ) ) {
	define( 'NEWSAI_AI_MAX_INPUT_CHARS', 25000 );
}

// Gemini models Google has retired from the public generativelanguage v1beta endpoint.
// Any saved option pointing at these is silently upgraded to the current default so
// existing Pro installs do not break after Google's lifecycle changes.
if ( ! defined( 'NEWSAI_AI_DEPRECATED_GEMINI_MODELS' ) ) {
	define(
		'NEWSAI_AI_DEPRECATED_GEMINI_MODELS',
		'gemini-1.0-pro,gemini-pro,gemini-1.5-flash,gemini-1.5-flash-latest,gemini-1.5-pro,gemini-1.5-pro-latest'
	);
}

if ( ! function_exists( 'newsai_ai_get_provider' ) ) {
	function newsai_ai_get_provider() {
		$p = (string) get_option( 'newsai_ai_provider', '' );
		return in_array( $p, [ 'openai', 'gemini', 'groq' ], true ) ? $p : '';
	}
}

if ( ! function_exists( 'newsai_ai_get_api_key' ) ) {
	/**
	 * @param string $provider 'openai' or 'gemini'. Defaults to active provider.
	 * @return string
	 */
	function newsai_ai_get_api_key( $provider = '' ) {
		if ( $provider === '' ) {
			$provider = newsai_ai_get_provider();
		}
		if ( $provider === 'openai' && function_exists( 'newsai_secret_get' ) ) {
			return newsai_secret_get( 'openai_api_key' );
		}
		if ( $provider === 'gemini' && function_exists( 'newsai_secret_get' ) ) {
			return newsai_secret_get( 'gemini_api_key' );
		}
		if ( $provider === 'groq' && function_exists( 'newsai_secret_get' ) ) {
			return newsai_secret_get( 'groq_api_key' );
		}
		return '';
	}
}

if ( ! function_exists( 'newsai_ai_get_model' ) ) {
	function newsai_ai_get_model( $provider = '' ) {
		if ( $provider === '' ) {
			$provider = newsai_ai_get_provider();
		}
		if ( $provider === 'openai' ) {
			$m = (string) get_option( 'newsai_ai_openai_model', NEWSAI_AI_DEFAULT_OPENAI_MODEL );
			return $m !== '' ? $m : NEWSAI_AI_DEFAULT_OPENAI_MODEL;
		}
		if ( $provider === 'gemini' ) {
			$m = (string) get_option( 'newsai_ai_gemini_model', NEWSAI_AI_DEFAULT_GEMINI_MODEL );
			if ( $m === '' ) {
				return NEWSAI_AI_DEFAULT_GEMINI_MODEL;
			}
			// Auto-migrate dead model names so older Pro installs still work after Google
			// retires a model — without forcing the admin to reopen settings.
			$dead = array_filter( array_map( 'trim', explode( ',', NEWSAI_AI_DEPRECATED_GEMINI_MODELS ) ) );
			if ( in_array( $m, $dead, true ) ) {
				return NEWSAI_AI_DEFAULT_GEMINI_MODEL;
			}
			return $m;
		}
		if ( $provider === 'groq' ) {
			$m = (string) get_option( 'newsai_ai_groq_model', NEWSAI_AI_DEFAULT_GROQ_MODEL );
			return $m !== '' ? $m : NEWSAI_AI_DEFAULT_GROQ_MODEL;
		}
		return '';
	}
}

if ( ! function_exists( 'newsai_ai_is_active' ) ) {
	/**
	 * @return bool True only if Pro license is valid AND a provider with an API key is configured.
	 */
	function newsai_ai_is_active() {
		if ( ! function_exists( 'newsai_is_pro' ) || ! newsai_is_pro() ) {
			return false;
		}
		$provider = newsai_ai_get_provider();
		if ( $provider === '' ) {
			return false;
		}
		return newsai_ai_get_api_key( $provider ) !== '';
	}
}

if ( ! function_exists( 'newsai_ai_daily_limit' ) ) {
	function newsai_ai_daily_limit() {
		$lim = (int) get_option( 'newsai_ai_daily_limit_per_user', NEWSAI_AI_DAILY_DEFAULT_LIMIT );
		if ( $lim < 0 ) {
			$lim = NEWSAI_AI_DAILY_DEFAULT_LIMIT;
		}
		return $lim;
	}
}

if ( ! function_exists( 'newsai_ai_rate_limit_key' ) ) {
	function newsai_ai_rate_limit_key( $user_id ) {
		return 'newsai_ai_count_' . (int) $user_id . '_' . gmdate( 'Ymd' );
	}
}

if ( ! function_exists( 'newsai_ai_rate_limit_remaining' ) ) {
	function newsai_ai_rate_limit_remaining( $user_id ) {
		$limit = newsai_ai_daily_limit();
		if ( $limit === 0 ) {
			return PHP_INT_MAX;
		}
		$count = (int) get_transient( newsai_ai_rate_limit_key( $user_id ) );
		$rem   = $limit - $count;
		return $rem > 0 ? $rem : 0;
	}
}

if ( ! function_exists( 'newsai_ai_rate_limit_increment' ) ) {
	function newsai_ai_rate_limit_increment( $user_id ) {
		$key   = newsai_ai_rate_limit_key( $user_id );
		$count = (int) get_transient( $key );
		set_transient( $key, $count + 1, DAY_IN_SECONDS );
	}
}

if ( ! function_exists( 'newsai_ai_truncate_for_prompt' ) ) {
	function newsai_ai_truncate_for_prompt( $text ) {
		$text = (string) $text;
		if ( $text === '' ) {
			return '';
		}
		$max = (int) apply_filters( 'newsai_ai_max_input_chars', NEWSAI_AI_MAX_INPUT_CHARS );
		if ( $max < 4000 ) {
			$max = 4000;
		}
		if ( function_exists( 'mb_strlen' ) ) {
			if ( mb_strlen( $text, 'UTF-8' ) <= $max ) {
				return $text;
			}
			return mb_substr( $text, 0, $max, 'UTF-8' );
		}
		if ( strlen( $text ) <= $max ) {
			return $text;
		}
		return substr( $text, 0, $max );
	}
}

if ( ! function_exists( 'newsai_ai_post_plain_content' ) ) {
	function newsai_ai_post_plain_content( $post_id ) {
		$post = get_post( (int) $post_id );
		if ( ! $post ) {
			return '';
		}
		$raw   = (string) $post->post_content;
		$text  = wp_strip_all_tags( $raw );
		$text  = preg_replace( '/\s+/u', ' ', $text );
		return trim( (string) $text );
	}
}

if ( ! function_exists( 'newsai_ai_call_openai' ) ) {
	/**
	 * @param string $system System prompt (instructions).
	 * @param string $user   User content payload.
	 * @return array{ok:bool, raw:string, error:string}
	 */
	function newsai_ai_call_openai( $system, $user ) {
		$key   = newsai_ai_get_api_key( 'openai' );
		$model = newsai_ai_get_model( 'openai' );
		if ( $key === '' ) {
			return [ 'ok' => false, 'raw' => '', 'error' => 'Missing OpenAI API key.' ];
		}
		$body = [
			'model'           => $model,
			'temperature'     => 0.25,
			'response_format' => [ 'type' => 'json_object' ],
			'messages'        => [
				[ 'role' => 'system', 'content' => $system ],
				[ 'role' => 'user',   'content' => $user ],
			],
		];
		$response = wp_remote_post(
			'https://api.openai.com/v1/chat/completions',
			[
				'timeout' => 30,
				'headers' => [
					'Authorization' => 'Bearer ' . $key,
					'Content-Type'  => 'application/json; charset=utf-8',
				],
				'body'    => wp_json_encode( $body ),
			]
		);
		if ( is_wp_error( $response ) ) {
			return [ 'ok' => false, 'raw' => '', 'error' => $response->get_error_message() ];
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			$msg  = 'OpenAI HTTP ' . $code;
			$json = json_decode( $raw, true );
			if ( is_array( $json ) && ! empty( $json['error']['message'] ) ) {
				$msg = sanitize_text_field( (string) $json['error']['message'] );
			}
			return [ 'ok' => false, 'raw' => $raw, 'error' => $msg ];
		}
		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) || empty( $json['choices'][0]['message']['content'] ) ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Malformed OpenAI response.' ];
		}
		return [ 'ok' => true, 'raw' => (string) $json['choices'][0]['message']['content'], 'error' => '' ];
	}
}

if ( ! function_exists( 'newsai_ai_call_gemini' ) ) {
	/**
	 * @param string $system System prompt (sent as preamble inside user text).
	 * @param string $user   User payload.
	 * @return array{ok:bool, raw:string, error:string}
	 */
	function newsai_ai_call_gemini( $system, $user ) {
		$key   = newsai_ai_get_api_key( 'gemini' );
		$model = newsai_ai_get_model( 'gemini' );
		if ( $key === '' ) {
			return [ 'ok' => false, 'raw' => '', 'error' => 'Missing Gemini API key.' ];
		}
		$endpoint = sprintf(
			'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
			rawurlencode( $model ),
			rawurlencode( $key )
		);
		$prompt = $system . "\n\n" . $user;
		$body   = [
			'contents'         => [
				[
					'role'  => 'user',
					'parts' => [ [ 'text' => $prompt ] ],
				],
			],
			'generationConfig' => [
				'temperature'      => 0.25,
				'responseMimeType' => 'application/json',
			],
		];
		$response = wp_remote_post(
			$endpoint,
			[
				'timeout' => (int) apply_filters( 'newsai_ai_gemini_timeout', 45 ),
				'headers' => [ 'Content-Type' => 'application/json; charset=utf-8' ],
				'body'    => wp_json_encode( $body ),
			]
		);
		if ( is_wp_error( $response ) ) {
			return [ 'ok' => false, 'raw' => '', 'error' => $response->get_error_message() ];
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			$msg  = 'Gemini HTTP ' . $code;
			$json = json_decode( $raw, true );
			if ( is_array( $json ) && ! empty( $json['error']['message'] ) ) {
				$msg = sanitize_text_field( (string) $json['error']['message'] );
			}
			// 404 "model not found" → guide the admin straight to the model field instead
			// of dumping Google's raw stack trace into the sidebar.
			if ( $code === 404 && ( stripos( $msg, 'not found' ) !== false || stripos( $msg, 'not supported' ) !== false ) ) {
				$msg .= ' — Settings → PRO → AI Assist → Gemini μοντέλο: δοκίμασε gemini-2.5-flash.';
			}
			return [ 'ok' => false, 'raw' => $raw, 'error' => $msg ];
		}
		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) || empty( $json['candidates'][0]['content']['parts'][0]['text'] ) ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Malformed Gemini response.' ];
		}
		return [ 'ok' => true, 'raw' => (string) $json['candidates'][0]['content']['parts'][0]['text'], 'error' => '' ];
	}
}

if ( ! function_exists( 'newsai_ai_call_groq' ) ) {
	/**
	 * Groq exposes an OpenAI-compatible Chat Completions API at
	 * https://api.groq.com/openai/v1/chat/completions — same payload shape as OpenAI,
	 * just a different base URL and (much) faster inference on Llama / GPT-OSS weights.
	 *
	 * @param string $system System prompt.
	 * @param string $user   User payload.
	 * @return array{ok:bool, raw:string, error:string}
	 */
	function newsai_ai_call_groq( $system, $user ) {
		$result = newsai_ai_call_groq_request( $system, $user, true );
		if ( ! empty( $result['ok'] ) ) {
			return $result;
		}

		$salvaged = newsai_ai_salvage_groq_failed_generation( (string) ( $result['raw'] ?? '' ) );
		if ( $salvaged !== '' ) {
			return [ 'ok' => true, 'raw' => $salvaged, 'error' => '' ];
		}

		$err = strtolower( (string) ( $result['error'] ?? '' ) );
		if ( strpos( $err, 'json' ) !== false || strpos( $err, 'failed to generate' ) !== false ) {
			$retry_user = $user . "\n\n" . newsai_ai_json_output_rules( '{"result":"value"}' );
			$retry      = newsai_ai_call_groq_request( $system, $retry_user, false );
			if ( ! empty( $retry['ok'] ) ) {
				return $retry;
			}
			$salvaged_retry = newsai_ai_salvage_groq_failed_generation( (string) ( $retry['raw'] ?? '' ) );
			if ( $salvaged_retry !== '' ) {
				return [ 'ok' => true, 'raw' => $salvaged_retry, 'error' => '' ];
			}
			return $retry;
		}

		return $result;
	}
}

if ( ! function_exists( 'newsai_ai_call_groq_request' ) ) {
	/**
	 * Single Groq HTTP request.
	 *
	 * @param string $system    System prompt.
	 * @param string $user      User payload.
	 * @param bool   $json_mode Use response_format json_object.
	 * @return array{ok:bool, raw:string, error:string}
	 */
	function newsai_ai_call_groq_request( $system, $user, $json_mode = true ) {
		$key   = newsai_ai_get_api_key( 'groq' );
		$model = newsai_ai_get_model( 'groq' );
		if ( $key === '' ) {
			return [ 'ok' => false, 'raw' => '', 'error' => 'Missing Groq API key.' ];
		}
		$body = [
			'model'       => $model,
			'temperature' => 0.25,
			'messages'    => [
				[ 'role' => 'system', 'content' => $system ],
				[ 'role' => 'user',   'content' => $user ],
			],
		];
		if ( $json_mode ) {
			$body['response_format'] = [ 'type' => 'json_object' ];
		}
		$response = wp_remote_post(
			'https://api.groq.com/openai/v1/chat/completions',
			[
				'timeout' => 30,
				'headers' => [
					'Authorization' => 'Bearer ' . $key,
					'Content-Type'  => 'application/json; charset=utf-8',
				],
				'body'    => wp_json_encode( $body ),
			]
		);
		if ( is_wp_error( $response ) ) {
			return [ 'ok' => false, 'raw' => '', 'error' => $response->get_error_message() ];
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			$msg  = 'Groq HTTP ' . $code;
			$json = json_decode( $raw, true );
			if ( is_array( $json ) && ! empty( $json['error']['message'] ) ) {
				$msg = sanitize_text_field( (string) $json['error']['message'] );
			}
			if ( $code === 404 || $code === 400 ) {
				if ( stripos( $msg, 'model' ) !== false && ( stripos( $msg, 'not found' ) !== false || stripos( $msg, 'decommission' ) !== false || stripos( $msg, 'does not exist' ) !== false ) ) {
					$msg .= ' — Settings → PRO → AI Assist → Groq μοντέλο: δοκίμασε llama-3.3-70b-versatile.';
				}
			}
			return [ 'ok' => false, 'raw' => $raw, 'error' => $msg ];
		}
		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) || empty( $json['choices'][0]['message']['content'] ) ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Malformed Groq response.' ];
		}
		return [ 'ok' => true, 'raw' => (string) $json['choices'][0]['message']['content'], 'error' => '' ];
	}
}

if ( ! function_exists( 'newsai_ai_extract_json' ) ) {
	/**
	 * Tolerant JSON extraction: providers sometimes wrap JSON in code fences or break strings.
	 *
	 * @param string $raw Raw text from the LLM.
	 * @return array|null
	 */
	function newsai_ai_extract_json( $raw ) {
		$raw = trim( (string) $raw );
		if ( $raw === '' ) {
			return null;
		}
		$raw = preg_replace( '/^\xEF\xBB\xBF/u', '', $raw );

		$candidates = [ $raw ];
		if ( preg_match( '/```(?:json)?\s*(\{[\s\S]*\}|\[[\s\S]*\])\s*```/u', $raw, $m ) ) {
			$candidates[] = trim( $m[1] );
		}
		$brace = newsai_ai_extract_json_object_substring( $raw );
		if ( $brace !== '' ) {
			$candidates[] = $brace;
		}

		foreach ( $candidates as $candidate ) {
			$decoded = json_decode( $candidate, true );
			if ( is_array( $decoded ) ) {
				return $decoded;
			}
			$repaired = newsai_ai_repair_json_candidate( $candidate );
			if ( $repaired !== $candidate ) {
				$decoded = json_decode( $repaired, true );
				if ( is_array( $decoded ) ) {
					return $decoded;
				}
			}
		}
		return null;
	}
}

if ( ! function_exists( 'newsai_ai_extract_json_object_substring' ) ) {
	/**
	 * First balanced {...} block in text (avoids greedy-regex failures).
	 *
	 * @param string $raw Raw LLM output.
	 * @return string
	 */
	function newsai_ai_extract_json_object_substring( $raw ) {
		$start = strpos( (string) $raw, '{' );
		if ( $start === false ) {
			return '';
		}
		$depth   = 0;
		$in_str  = false;
		$escape  = false;
		$len     = strlen( $raw );
		for ( $i = $start; $i < $len; $i++ ) {
			$ch = $raw[ $i ];
			if ( $in_str ) {
				if ( $escape ) {
					$escape = false;
					continue;
				}
				if ( $ch === '\\' ) {
					$escape = true;
					continue;
				}
				if ( $ch === '"' ) {
					$in_str = false;
				}
				continue;
			}
			if ( $ch === '"' ) {
				$in_str = true;
				continue;
			}
			if ( $ch === '{' ) {
				++$depth;
			} elseif ( $ch === '}' ) {
				--$depth;
				if ( $depth === 0 ) {
					return substr( $raw, $start, $i - $start + 1 );
				}
			}
		}
		return '';
	}
}

if ( ! function_exists( 'newsai_ai_repair_json_candidate' ) ) {
	/**
	 * @param string $json JSON-ish string.
	 * @return string
	 */
	function newsai_ai_repair_json_candidate( $json ) {
		$json = trim( (string) $json );
		$json = preg_replace( '/,\s*([\]}])/u', '$1', $json );
		return is_string( $json ) ? $json : '';
	}
}

if ( ! function_exists( 'newsai_ai_extract_string_field' ) ) {
	/**
	 * Last-resort string field extraction when json_decode fails (e.g. Groq broken JSON).
	 *
	 * @param string $raw   Raw output.
	 * @param string $field JSON key.
	 * @return string
	 */
	function newsai_ai_extract_string_field( $raw, $field ) {
		$field = preg_quote( (string) $field, '/' );
		if ( preg_match( '/"' . $field . '"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"/su', (string) $raw, $m ) ) {
			return trim( stripcslashes( $m[1] ) );
		}
		return '';
	}
}

if ( ! function_exists( 'newsai_ai_salvage_groq_failed_generation' ) ) {
	/**
	 * Groq may return HTTP 400 with error.failed_generation containing partial JSON.
	 *
	 * @param string $response_body Full HTTP body.
	 * @return string Salvaged JSON substring or empty.
	 */
	function newsai_ai_salvage_groq_failed_generation( $response_body ) {
		$json = json_decode( (string) $response_body, true );
		if ( ! is_array( $json ) || empty( $json['error']['failed_generation'] ) ) {
			return '';
		}
		$failed = trim( (string) $json['error']['failed_generation'] );
		if ( $failed === '' ) {
			return '';
		}
		$parsed = newsai_ai_extract_json( $failed );
		if ( is_array( $parsed ) ) {
			return $failed;
		}
		$sub = newsai_ai_extract_json_object_substring( $failed );
		return $sub !== '' ? $sub : $failed;
	}
}

if ( ! function_exists( 'newsai_ai_json_output_rules' ) ) {
	/**
	 * Shared JSON contract for OpenAI, Gemini, and Groq (json_object / application/json).
	 *
	 * @param string $schema_example Example JSON shape (structure only).
	 * @return string
	 */
	function newsai_ai_json_output_rules( $schema_example ) {
		return 'OUTPUT FORMAT (mandatory): Return exactly ONE valid JSON object. '
			. 'No markdown fences. No commentary before or after JSON. '
			. 'String values must be valid JSON strings: escape " as \\", use \\n for line breaks (no raw newlines inside values). '
			. 'Use only the keys in the schema — no extra keys. '
			. 'Schema: ' . (string) $schema_example;
	}
}

if ( ! function_exists( 'newsai_ai_seo_editor_preamble' ) ) {
	/**
	 * Shared SEO / newsroom editorial role for AI tasks.
	 *
	 * @return string
	 */
	function newsai_ai_seo_editor_preamble() {
		return 'You are a senior news SEO editor for Google Discover, Google News, and search snippets. '
			. 'Optimize for entity clarity, E-E-A-T, mobile readability, and factual precision. '
			. 'Never invent facts, quotes, statistics, or sources not supported by the supplied article.';
	}
}

if ( ! function_exists( 'newsai_ai_salvage_partial_json' ) ) {
	/**
	 * Recover single-key payloads when json_decode fails (common with Groq + long Greek text).
	 *
	 * @param string $raw Raw model output.
	 * @return array<string,mixed>|null
	 */
	function newsai_ai_salvage_partial_json( $raw ) {
		$parsed = newsai_ai_extract_json( $raw );
		if ( is_array( $parsed ) ) {
			return $parsed;
		}
		$keys = [ 'text', 'meta_description', 'summary', 'angle' ];
		foreach ( $keys as $key ) {
			$val = newsai_ai_extract_string_field( $raw, $key );
			if ( $val !== '' ) {
				return [ $key => $val ];
			}
		}
		return null;
	}
}

if ( ! function_exists( 'newsai_ai_format_error_for_ui' ) ) {
	/**
	 * Append a short hint when fallback providers were tried.
	 *
	 * @param string $error Error trail from newsai_ai_call().
	 */
	function newsai_ai_format_error_for_ui( $error ) {
		$error = trim( (string) $error );
		if ( $error === '' ) {
			return __( 'AI request failed.', 'newsroom-ai-assistant' );
		}
		$active = function_exists( 'newsai_ai_get_provider' ) ? newsai_ai_get_provider() : '';
		if ( $active !== '' && stripos( $error, '→' ) !== false && stripos( $error, 'Groq:' ) !== false ) {
			$labels = [
				'gemini' => 'Gemini',
				'groq'   => 'Groq',
				'openai' => 'OpenAI',
			];
			$primary = isset( $labels[ $active ] ) ? $labels[ $active ] : ucfirst( $active );
			$error  .= ' ' . sprintf(
				/* translators: %s: primary AI provider label (e.g. Gemini) */
				__( '(Primary: %s — fix that API key/quota first; Groq was only a fallback.)', 'newsroom-ai-assistant' ),
				$primary
			);
		}
		return $error;
	}
}

if ( ! function_exists( 'newsai_ai_provider_label' ) ) {
	/**
	 * @param string $provider openai|gemini|groq
	 */
	function newsai_ai_provider_label( $provider ) {
		$labels = [
			'openai' => 'OpenAI',
			'gemini' => 'Gemini',
			'groq'   => 'Groq',
		];
		$provider = (string) $provider;
		return isset( $labels[ $provider ] ) ? $labels[ $provider ] : ucfirst( $provider );
	}
}

if ( ! function_exists( 'newsai_ai_provider_call' ) ) {
	/**
	 * Dispatch to a specific provider adapter.
	 *
	 * @param string $provider openai|gemini|groq
	 * @param string $system   System prompt.
	 * @param string $user     User payload.
	 * @return array{ok:bool, raw:string, error:string}
	 */
	function newsai_ai_provider_call( $provider, $system, $user ) {
		if ( $provider === 'openai' ) {
			return newsai_ai_call_openai( $system, $user );
		}
		if ( $provider === 'groq' ) {
			return newsai_ai_call_groq( $system, $user );
		}
		return newsai_ai_call_gemini( $system, $user );
	}
}

if ( ! function_exists( 'newsai_ai_error_is_retryable' ) ) {
	/**
	 * Best-effort detection of transient failures.
	 *
	 * @param string $error Error text.
	 * @return bool
	 */
	function newsai_ai_error_is_retryable( $error ) {
		$e = strtolower( trim( (string) $error ) );
		if ( $e === '' ) {
			return false;
		}
		$needles = [
			'timeout',
			'timed out',
			'temporar',
			'rate limit',
			'too many requests',
			'http 429',
			'http 500',
			'http 502',
			'http 503',
			'http 504',
			'connection',
			'network',
			'unavailable',
		];
		foreach ( $needles as $n ) {
			if ( strpos( $e, $n ) !== false ) {
				return true;
			}
		}
		return false;
	}
}

if ( ! function_exists( 'newsai_ai_provider_fallback_chain' ) ) {
	/**
	 * Active provider first, then other configured providers with keys (automatic fallback).
	 *
	 * @return string[]
	 */
	function newsai_ai_provider_fallback_chain() {
		$active = newsai_ai_get_provider();
		$order  = [];
		if ( $active !== '' ) {
			$order[] = $active;
		}
		foreach ( [ 'openai', 'gemini', 'groq' ] as $p ) {
			if ( in_array( $p, $order, true ) ) {
				continue;
			}
			if ( newsai_ai_get_api_key( $p ) !== '' ) {
				$order[] = $p;
			}
		}
		return apply_filters( 'newsai_ai_provider_fallback_chain', $order );
	}
}

if ( ! function_exists( 'newsai_ai_call' ) ) {
	/**
	 * Dispatch a task to the active provider with caching + per-user daily quota.
	 *
	 * @param string $system System prompt.
	 * @param string $user   User payload.
	 * @param string $cache_seed Stable seed for the cache key (typically content hash + task slug).
	 * @return array{ok:bool, data:array|null, error:string, cached:bool, remaining:int}
	 */
	function newsai_ai_call( $system, $user, $cache_seed ) {
		$user_id = get_current_user_id();
		if ( ! newsai_ai_is_active() ) {
			return [ 'ok' => false, 'data' => null, 'error' => 'AI not configured.', 'cached' => false, 'remaining' => 0 ];
		}
		if ( newsai_ai_rate_limit_remaining( $user_id ) <= 0 ) {
			return [ 'ok' => false, 'data' => null, 'error' => 'Daily AI limit reached for this user.', 'cached' => false, 'remaining' => 0 ];
		}

		$provider  = newsai_ai_get_provider();
		$model     = newsai_ai_get_model( $provider );
		$cache_key = 'newsai_ai_' . md5( $provider . '|' . $model . '|' . $cache_seed );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			if ( isset( $cached['data'] ) && is_array( $cached['data'] ) ) {
				return [
					'ok'        => true,
					'data'      => $cached['data'],
					'error'     => '',
					'cached'    => true,
					'provider'  => (string) ( $cached['provider'] ?? $provider ),
					'remaining' => newsai_ai_rate_limit_remaining( $user_id ),
				];
			}
			// Legacy cache shape (raw JSON array).
			return [
				'ok'        => true,
				'data'      => $cached,
				'error'     => '',
				'cached'    => true,
				'provider'  => $provider,
				'remaining' => newsai_ai_rate_limit_remaining( $user_id ),
			];
		}

		$providers = newsai_ai_provider_fallback_chain();
		if ( empty( $providers ) ) {
			return [ 'ok' => false, 'data' => null, 'error' => 'No AI provider with API key is configured.', 'cached' => false, 'provider' => '', 'remaining' => newsai_ai_rate_limit_remaining( $user_id ) ];
		}
		$max_attempts = (int) apply_filters( 'newsai_ai_retry_attempts', 2 );
		if ( $max_attempts < 1 ) {
			$max_attempts = 1;
		}
		$last_error    = 'AI request failed.';
		$json          = null;
		$used_provider = '';
		$error_trail   = [];
		foreach ( $providers as $idx => $p ) {
			$label    = newsai_ai_provider_label( $p );
			$attempts = ( 0 === $idx ) ? max( $max_attempts, 2 ) : $max_attempts;
			for ( $attempt = 1; $attempt <= $attempts; $attempt++ ) {
				$result = newsai_ai_provider_call( $p, $system, $user );
				if ( ! empty( $result['ok'] ) ) {
					$raw_body = (string) $result['raw'];
					$json     = newsai_ai_extract_json( $raw_body );
					if ( ! is_array( $json ) && function_exists( 'newsai_ai_salvage_partial_json' ) ) {
						$json = newsai_ai_salvage_partial_json( $raw_body );
					}
					if ( is_array( $json ) ) {
						$used_provider = $p;
						break 2;
					}
					$err_detail  = __( 'AI returned non-JSON output.', 'newsroom-ai-assistant' );
					$last_error  = $label . ': ' . $err_detail;
					$error_trail[] = $last_error;
					if ( $attempt >= $attempts ) {
						break;
					}
					continue;
				}
				$err_detail  = (string) ( $result['error'] ?? 'AI request failed.' );
				$last_error  = $label . ': ' . $err_detail;
				$error_trail[] = $last_error;
				if ( ! newsai_ai_error_is_retryable( $err_detail ) ) {
					break;
				}
			}
		}
		if ( ! is_array( $json ) && count( $error_trail ) > 1 ) {
			$last_error = implode( ' → ', $error_trail );
		}
		if ( ! is_array( $json ) ) {
			$last_error = function_exists( 'newsai_ai_format_error_for_ui' )
				? newsai_ai_format_error_for_ui( $last_error )
				: $last_error;
			return [ 'ok' => false, 'data' => null, 'error' => $last_error, 'cached' => false, 'provider' => '', 'remaining' => newsai_ai_rate_limit_remaining( $user_id ) ];
		}

		newsai_ai_rate_limit_increment( $user_id );
		set_transient(
			$cache_key,
			[
				'data'     => $json,
				'provider' => $used_provider,
			],
			NEWSAI_AI_CACHE_TTL
		);

		return [
			'ok'        => true,
			'data'      => $json,
			'error'     => '',
			'cached'    => false,
			'provider'  => $used_provider,
			'remaining' => newsai_ai_rate_limit_remaining( $user_id ),
		];
	}
}

if ( ! function_exists( 'newsai_ai_provider_supports_vision' ) ) {
	/**
	 * @param string $provider openai|gemini|groq
	 */
	function newsai_ai_provider_supports_vision( $provider ) {
		return in_array( (string) $provider, [ 'openai', 'gemini' ], true );
	}
}

if ( ! function_exists( 'newsai_ai_attachment_image_payload' ) ) {
	/**
	 * @return array{ok:bool,path:string,mime:string,base64:string,data_url:string,error:string}
	 */
	function newsai_ai_attachment_image_payload( $attachment_id ) {
		$attachment_id = (int) $attachment_id;
		$path          = get_attached_file( $attachment_id );
		if ( ! is_string( $path ) || $path === '' || ! file_exists( $path ) ) {
			return [ 'ok' => false, 'path' => '', 'mime' => '', 'base64' => '', 'data_url' => '', 'error' => 'Image file missing.' ];
		}
		$size = (int) @filesize( $path );
		if ( $size > 4 * 1024 * 1024 ) {
			return [ 'ok' => false, 'path' => $path, 'mime' => '', 'base64' => '', 'data_url' => '', 'error' => 'Image too large for AI alt (max 4 MB).' ];
		}
		$mime = (string) get_post_mime_type( $attachment_id );
		if ( $mime === '' ) {
			$mime = 'image/jpeg';
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = file_get_contents( $path );
		if ( $raw === false || $raw === '' ) {
			return [ 'ok' => false, 'path' => $path, 'mime' => $mime, 'base64' => '', 'data_url' => '', 'error' => 'Could not read image file.' ];
		}
		$b64 = base64_encode( $raw );
		return [
			'ok'       => true,
			'path'     => $path,
			'mime'     => $mime,
			'base64'   => $b64,
			'data_url' => 'data:' . $mime . ';base64,' . $b64,
			'error'    => '',
		];
	}
}

if ( ! function_exists( 'newsai_ai_call_openai_vision' ) ) {
	function newsai_ai_call_openai_vision( $system, $user_text, $data_url ) {
		$key   = newsai_ai_get_api_key( 'openai' );
		$model = newsai_ai_get_model( 'openai' );
		if ( $key === '' ) {
			return [ 'ok' => false, 'raw' => '', 'error' => 'Missing OpenAI API key.' ];
		}
		$body = [
			'model'           => $model,
			'temperature'     => 0.3,
			'response_format' => [ 'type' => 'json_object' ],
			'messages'        => [
				[ 'role' => 'system', 'content' => $system ],
				[
					'role'    => 'user',
					'content' => [
						[ 'type' => 'text', 'text' => $user_text ],
						[ 'type' => 'image_url', 'image_url' => [ 'url' => $data_url ] ],
					],
				],
			],
		];
		$response = wp_remote_post(
			'https://api.openai.com/v1/chat/completions',
			[
				'timeout' => 45,
				'headers' => [
					'Authorization' => 'Bearer ' . $key,
					'Content-Type'  => 'application/json',
				],
				'body'    => wp_json_encode( $body ),
			]
		);
		if ( is_wp_error( $response ) ) {
			return [ 'ok' => false, 'raw' => '', 'error' => $response->get_error_message() ];
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'OpenAI HTTP ' . $code ];
		}
		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) || empty( $json['choices'][0]['message']['content'] ) ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Malformed OpenAI vision response.' ];
		}
		return [ 'ok' => true, 'raw' => (string) $json['choices'][0]['message']['content'], 'error' => '' ];
	}
}

if ( ! function_exists( 'newsai_ai_call_gemini_vision' ) ) {
	function newsai_ai_call_gemini_vision( $system, $user_text, $base64, $mime ) {
		$key   = newsai_ai_get_api_key( 'gemini' );
		$model = newsai_ai_get_model( 'gemini' );
		if ( $key === '' ) {
			return [ 'ok' => false, 'raw' => '', 'error' => 'Missing Gemini API key.' ];
		}
		$endpoint = sprintf(
			'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
			rawurlencode( $model ),
			rawurlencode( $key )
		);
		$body = [
			'contents'         => [
				[
					'role'  => 'user',
					'parts' => [
						[ 'text' => $system . "\n\n" . $user_text ],
						[
							'inline_data' => [
								'mime_type' => (string) $mime,
								'data'      => (string) $base64,
							],
						],
					],
				],
			],
			'generationConfig' => [
				'temperature'      => 0.3,
				'responseMimeType' => 'application/json',
			],
		];
		$response = wp_remote_post(
			$endpoint,
			[
				'timeout' => 45,
				'headers' => [ 'Content-Type' => 'application/json; charset=utf-8' ],
				'body'    => wp_json_encode( $body ),
			]
		);
		if ( is_wp_error( $response ) ) {
			return [ 'ok' => false, 'raw' => '', 'error' => $response->get_error_message() ];
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Gemini HTTP ' . $code ];
		}
		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) || empty( $json['candidates'][0]['content']['parts'][0]['text'] ) ) {
			return [ 'ok' => false, 'raw' => $raw, 'error' => 'Malformed Gemini vision response.' ];
		}
		return [ 'ok' => true, 'raw' => (string) $json['candidates'][0]['content']['parts'][0]['text'], 'error' => '' ];
	}
}

if ( ! function_exists( 'newsai_ai_call_vision_json' ) ) {
	/**
	 * Vision JSON call with cache + quota (OpenAI/Gemini vision; text-only fallback for Groq).
	 *
	 * @return array{ok:bool, data:array|null, error:string, remaining:int}
	 */
	function newsai_ai_call_vision_json( $attachment_id, $system, $user_text, $cache_seed ) {
		$user_id = get_current_user_id();
		if ( ! newsai_ai_is_active() ) {
			return [ 'ok' => false, 'data' => null, 'error' => 'AI not configured.', 'remaining' => 0 ];
		}
		if ( $user_id > 0 && newsai_ai_rate_limit_remaining( $user_id ) <= 0 ) {
			return [ 'ok' => false, 'data' => null, 'error' => 'Daily AI limit reached for this user.', 'remaining' => 0 ];
		}

		$provider  = newsai_ai_get_provider();
		$model     = newsai_ai_get_model( $provider );
		$cache_key = 'newsai_ai_vis_' . md5( $provider . '|' . $model . '|' . $cache_seed );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return [ 'ok' => true, 'data' => $cached, 'error' => '', 'remaining' => newsai_ai_rate_limit_remaining( $user_id ) ];
		}

		$img = newsai_ai_attachment_image_payload( $attachment_id );
		$json = null;
		$last_error = 'AI request failed.';

		if ( ! empty( $img['ok'] ) && newsai_ai_provider_supports_vision( $provider ) ) {
			if ( $provider === 'openai' ) {
				$result = newsai_ai_call_openai_vision( $system, $user_text, (string) $img['data_url'] );
			} else {
				$result = newsai_ai_call_gemini_vision( $system, $user_text, (string) $img['base64'], (string) $img['mime'] );
			}
			if ( ! empty( $result['ok'] ) ) {
				$json = newsai_ai_extract_json( (string) $result['raw'] );
			} else {
				$last_error = (string) ( $result['error'] ?? $last_error );
			}
		}

		if ( ! is_array( $json ) ) {
			$fallback_user = $user_text . "\n\nNote: image bytes unavailable — infer alt from metadata only.";
			$text_result   = newsai_ai_call( $system, $fallback_user, $cache_seed . '|text' );
			if ( ! empty( $text_result['ok'] ) && is_array( $text_result['data'] ) ) {
				return $text_result;
			}
			return [ 'ok' => false, 'data' => null, 'error' => $last_error, 'remaining' => newsai_ai_rate_limit_remaining( $user_id ) ];
		}

		if ( $user_id > 0 ) {
			newsai_ai_rate_limit_increment( $user_id );
		}
		set_transient( $cache_key, $json, NEWSAI_AI_CACHE_TTL );
		return [ 'ok' => true, 'data' => $json, 'error' => '', 'remaining' => newsai_ai_rate_limit_remaining( $user_id ) ];
	}
}
