<?php
/**
 * Costudio_Search_Engine_ContentOS
 *
 * Native search engine backed by the ContentOS App `/ai/search` endpoint.
 * The backend uses Gemini grounding to return structured sources
 * (url, title, snippet, …) so the plugin no longer depends on the LLM
 * to transcribe URLs from text.
 *
 * Used by the Pipeline when ContentOS Auto is active and the "Use ContentOS
 * Real-time Search (native)" toggle is ON. BYOK search uses the provider keys
 * configured locally in the plugin.
 *
 * @package Contentosapp_Studio
 * @since   3.3.0
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Search_Engine_ContentOS {

	const API_HOST     = 'api.contentosapp.com';
	const API_ENDPOINT = COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/ai/search';

	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 DEFAULT_TIMEOUT = 60;
	const DEFAULT_RESULTS = 8;

	/**
	 * Run a search against /ai/search.
	 *
	 * @param string $query
	 * @param array  $opts {
	 *     @type int    $production_id  Binds the call to a production for quota tracking.
	 *                                  When 0 (default), the call counts as a connection
	 *                                  test and is validated by the ContentOS service.
	 *     @type int    $max_results    Default 8.
	 *     @type string $depth          'shallow' | 'deep'. Default 'shallow'.
	 *     @type string $locale         Default get_locale().
	 *     @type int    $timeout        Default 60. Used as the per-request ceiling;
	 *                                  when $deadline is also set, the smaller of
	 *                                  the two governs each individual HTTP call.
	 *     @type float  $deadline       Optional absolute microtime(true) budget for
	 *                                  the WHOLE call (all attempts + fallback +
	 *                                  retry wait combined). Re-checked before every
	 *                                  transport attempt and before the 503 retry
	 *                                  sleep, so a caller with a hard wall-clock
	 *                                  budget (e.g. Costudio_Video_Discovery) never
	 *                                  overruns it — without a deadline, this method
	 *                                  can take up to ~4x $timeout plus a 3s sleep
	 *                                  in the worst case (2 attempts x up to 2
	 *                                  transports each).
	 * }
	 * @return array {
	 *     @type bool   $ok                Whether the call succeeded.
	 *     @type array  $sources           Structured source list (empty on error).
	 *     @type string $summary_markdown  Backend-provided summary (may be '').
	 *     @type string $model_used        Reported by backend (informational).
	 *     @type string $error             Error message when $ok === false.
	 * }
	 */
	public function search( string $query, array $opts = array() ): array {
		$query = trim( $query );
		if ( $query === '' ) {
			return $this->fail( 'Empty search query.' );
		}

		$license_key = $this->get_license_key();

		$depth = $opts['depth'] ?? 'shallow';
		if ( ! in_array( $depth, array( 'shallow', 'deep' ), true ) ) {
			$depth = 'shallow';
		}

		$production_id  = isset( $opts['production_id'] ) ? max( 0, (int) $opts['production_id'] ) : 0;
		$production_uid = '';
		if ( $production_id > 0 && class_exists( 'Costudio_Production' ) ) {
			$production_uid = Costudio_Production::tracking_uid_for( $production_id );
		}

		$body = array(
			'production_id' => $production_id,
			'query'         => mb_substr( $query, 0, 500 ),
			'max_results'   => isset( $opts['max_results'] ) ? max( 1, min( 20, (int) $opts['max_results'] ) ) : self::DEFAULT_RESULTS,
			'depth'         => $depth,
			'locale'        => $opts['locale'] ?? ( function_exists( 'get_locale' ) ? get_locale() : 'en_US' ),
			'site_url'      => function_exists( 'get_site_url' ) ? get_site_url() : '',
		);
		if ( class_exists( 'Costudio_Telemetry' ) && method_exists( 'Costudio_Telemetry', 'current_site_hash' ) ) {
			$body['site_hash'] = Costudio_Telemetry::current_site_hash();
		}
		if ( '' !== $production_uid ) {
			$body['production_uid'] = $production_uid;
		}

		$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 used for both transports. Origin/Referer/X-Requested-With mimic
		// a browser admin-ajax call so WAFs (LiteSpeed, Cloudflare) and reCAPTCHA
		// Enterprise let the request through, the same way the image generator
		// and telemetry endpoints already do.
		$headers = array(
			'Content-Type'     => 'application/json',
			'Accept'           => 'application/json, */*;q=0.8',
			'User-Agent'       => self::REMOTE_BROWSER_UA,
			'X-FOS-Site-ID'    => $fingerprint,
			'X-Requested-With' => 'XMLHttpRequest',
			'Origin'           => COSTUDIO_API_URL,
			'Referer'          => COSTUDIO_API_URL . '/wp-admin/',
		);

		$encoded = wp_json_encode( $body );

		if ( '' !== $license_key ) {
			$headers['Authorization'] = 'Bearer ' . $license_key;
		}

		if ( '' !== $license_key && class_exists( 'Costudio_Standalone_Crypto' ) ) {
			$secret = class_exists( 'Costudio_Standalone_License' )
				? Costudio_Standalone_License::get_signing_secret( $license_key )
				: $license_key;
			$headers['X-FOS-Signature'] = Costudio_Standalone_Crypto::sign_payload( $encoded, $secret );
		}

		$timeout = isset( $opts['timeout'] ) ? (int) $opts['timeout'] : self::DEFAULT_TIMEOUT;
		$timeout = (int) apply_filters( 'costudio_contentos_search_timeout', max( 15, $timeout ) );

		$deadline = isset( $opts['deadline'] ) ? (float) $opts['deadline'] : null;
		// Floor below which a new HTTP attempt isn't even worth starting —
		// mirrors Costudio_Video_Discovery's own budget floors.
		$min_call_seconds = 3.0;

		/**
		 * Recompute the timeout for the NEXT transport call from whatever
		 * deadline remains right now. Returns null when there isn't enough
		 * budget left to justify attempting the call at all.
		 */
		$call_timeout = static function () use ( $deadline, $timeout, $min_call_seconds ): ?int {
			if ( null === $deadline ) {
				return $timeout;
			}
			$remaining = $deadline - microtime( true );
			if ( $remaining < $min_call_seconds ) {
				return null;
			}
			return (int) max( (int) $min_call_seconds, min( $timeout, (int) floor( $remaining ) ) );
		};

		$response  = null;
		$transport = 'ajax';
		$raw       = '';
		$code      = 0;
		$decoded   = null;

		for ( $attempt = 0; $attempt <= 1; $attempt++ ) {
			if ( $attempt > 0 ) {
				// 503 = transient Gemini overload — retry once after a brief pause,
				// but only if the deadline (when set) can actually absorb the
				// 3s wait plus a subsequent attempt.
				if ( null !== $deadline && ( $deadline - microtime( true ) ) < ( 3.0 + $min_call_seconds ) ) {
					break;
				}
				sleep( 3 );
			}

			$this_timeout = $call_timeout();
			if ( null === $this_timeout ) {
				return $this->fail( 'Deadline exceeded before the search request could be attempted.' );
			}

			// 1) Try admin-ajax.php first — reCAPTCHA Enterprise / WAFs typically
			//    don't intercept admin-ajax because wp-admin itself depends on it.
			$response = wp_remote_post(
				costudio_api_ajax_url( 'cap_api_search' ),
				array(
					'timeout' => $this_timeout,
					'headers' => $headers,
					'body'    => $encoded,
				)
			);

			$transport  = 'ajax';
			$raw        = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
			$trim       = ltrim( $raw );
			$looks_json = $trim !== '' && ( $trim[0] === '{' || $trim[0] === '[' );

			// 2) If admin-ajax wasn't registered (`0`) or returned non-JSON
			//    (HTML challenge page), fall back to the REST endpoint — but
			//    only if the deadline still allows a second call.
			if ( is_wp_error( $response ) || ! $looks_json || $trim === '0' ) {
				$this_timeout = $call_timeout();
				if ( null === $this_timeout ) {
					return $this->fail( 'Deadline exceeded before the REST fallback could be attempted.' );
				}
				$response = wp_remote_post(
					self::API_ENDPOINT,
					array(
						'timeout' => $this_timeout,
						'headers' => $headers,
						'body'    => $encoded,
					)
				);
				$transport = 'rest';
				$raw       = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
			}

			if ( is_wp_error( $response ) ) {
				return $this->fail( 'HTTP error (' . $transport . '): ' . $response->get_error_message() );
			}

			$code    = (int) wp_remote_retrieve_response_code( $response );
			$decoded = json_decode( $raw, true );

			// Only retry on 503 (transient backend overload) — break on any other outcome.
			if ( $code !== 503 ) {
				break;
			}
		}

		if ( $code < 200 || $code >= 300 ) {
			$msg = '';
			if ( is_array( $decoded ) ) {
				// Backend uses { error: { message, code } } via CAP_Routes::error_response.
				$msg = $decoded['error']['message']
					?? $decoded['error_message']
					?? $decoded['message']
					?? '';
			}
			return $this->fail( 'HTTP ' . $code . ( $msg ? ' — ' . $msg : '' ) );
		}

		if ( ! is_array( $decoded ) ) {
			// Detect transparent firewall block (reCAPTCHA / Cloudflare / LiteSpeed
			// bot verification) — same pattern as Costudio_Image_Generator.
			if ( stripos( $raw, '<html' ) !== false && (
				stripos( $raw, 'grecaptcha' ) !== false
				|| stripos( $raw, 'recaptcha' ) !== false
				|| stripos( $raw, 'cloudflare' ) !== false
				|| stripos( $raw, 'bot verification' ) !== false
			) ) {
				return $this->fail( sprintf(
					'Blocked by reCAPTCHA/WAF on contentosapp.com (transport=%s, HTTP %d). The /ai/search endpoint returned an HTML challenge page instead of JSON.',
					$transport,
					$code
				) );
			}

			costudio_log_debug( 'contentos.search_invalid_json', [
				'transport' => $transport,
				'http'      => $code,
				'body'      => $raw,
			] );
			return $this->fail( sprintf(
				'Invalid JSON response from /ai/search [transport=%s HTTP %d].',
				$transport,
				$code
			) );
		}

		// Service returned HTTP 200 but search is gated for this plan.
		if ( ! empty( $decoded['gated'] ) ) {
			return array(
				'ok'               => false,
				'gated'            => true,
				'reason'           => (string) ( $decoded['reason'] ?? 'search_unavailable_on_starter' ),
				'sources'          => array(),
				'summary_markdown' => '',
				'model_used'       => '',
				'error'            => '',
			);
		}

		if ( empty( $decoded['ok'] ) ) {
			$msg = $decoded['error']['message']
				?? $decoded['error_message']
				?? 'Backend returned ok=false.';
			return $this->fail( (string) $msg );
		}

		return array(
			'ok'               => true,
			'sources'          => is_array( $decoded['sources'] ?? null ) ? array_values( $decoded['sources'] ) : array(),
			'summary_markdown' => (string) ( $decoded['summary_markdown'] ?? '' ),
			'model_used'       => (string) ( $decoded['model_used'] ?? '' ),
			'error'            => '',
		);
	}

	/**
	 * Convert a /ai/search result into the markdown blob the pipeline prepends
	 * to research-agent prompts.
	 *
	 * Sources are emitted as a numbered list so the Researcher (Agent 03) can
	 * cite them with anchored `{{ref:N}}` placeholders instead of transcribing
	 * URLs — removing the LLM URL hallucination class of bugs entirely. The
	 * summary_markdown blob (when present) is appended below the index.
	 *
	 * @param array $result
	 * @return string
	 */
	public static function to_prompt_markdown( array $result ): string {
		if ( empty( $result['ok'] ) ) {
			return '';
		}

		$blocks  = array();
		$sources = is_array( $result['sources'] ?? null ) ? array_values( $result['sources'] ) : array();

		if ( ! empty( $sources ) ) {
			$lines = array(
				'## INDEXED SOURCES — cite with {{ref:N}} placeholders',
				'When you reference any of these sources, link a concise factual phrase as `[descriptive anchor]({{ref:N}})` (e.g. `[official source data]({{ref:1}})`). Do not write bare `{{ref:N}}` at sentence endings. Never transcribe URLs literally.',
				'',
			);
			foreach ( $sources as $i => $src ) {
				$n       = $i + 1;
				$title   = trim( (string) ( $src['title']   ?? '' ) );
				$snippet = trim( (string) ( $src['snippet'] ?? '' ) );
				if ( trim( (string) ( $src['url'] ?? '' ) ) === '' ) {
					continue;
				}
				$label   = $title !== '' ? $title : "Source {$n}";
				$lines[] = "{$n}. **{$label}** — {$snippet}";
			}
			$blocks[] = implode( "\n", $lines );
		}

		$summary = trim( (string) ( $result['summary_markdown'] ?? '' ) );
		if ( $summary !== '' ) {
			$blocks[] = "## SEARCH SUMMARY\n" . $summary;
		}

		return implode( "\n\n---\n\n", $blocks );
	}

	// ── Internals ────────────────────────────────────────────────────────────

	private function fail( string $msg ): array {
		return array(
			'ok'               => false,
			'sources'          => array(),
			'summary_markdown' => '',
			'model_used'       => '',
			'error'            => $msg,
		);
	}

	private function get_license_key(): string {
		// Standalone — single source of truth used everywhere else in Contentosapp Studio.
		$key = (string) get_option( 'costudio_license_key', '' );
		if ( $key !== '' ) {
			return $key;
		}

		// Module mode (ContentOS Core present) — fall back to its license object.
		$core_license = 'contentos_' . 'license';
		if ( function_exists( $core_license ) ) {
			$cl = $core_license();
			if ( $cl && method_exists( $cl, 'get_key' ) ) {
				$key = (string) $cl->get_key();
				if ( $key !== '' ) {
					return $key;
				}
			}
		}

		return '';
	}

}
