<?php
/**
 * Spark Adapter — Handles Codex Spark-specific tool use.
 *
 * GPT-5.3-Codex-Spark supports function calling but has known quirks:
 * - Malformed JSON in tool call arguments
 * - Lower reliability for multi-step tool flows
 * - May skip tool calls and answer directly for conversational turns
 *
 * This adapter wraps the standard RLM tool-calling pipeline and provides
 * a Spark-aware fallback: when tool calling fails, it pre-searches locally
 * and sends Spark a single completion with embedded context — always using
 * Spark (never falling back to a different model the user may not have).
 *
 * @package Niwa_AI
 * @since   0.10.69
 */

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

class Niwa_AI_Spark_Adapter {

	/** @var Niwa_AI_AI_Client_Interface */
	private $openai_client;

	/** @var Niwa_AI_Auth_Provider_Interface */
	private $auth_manager;

	/** @var Niwa_AI_Memory_Searcher|null */
	private $memory_searcher;

	/** @var Niwa_AI_Humanizer|null */
	private $humanizer;

	/** @var Niwa_AI_Content_Store_Interface|null */
	private $content_store;

	private const SPARK_TOOL_TIMEOUT  = 35;
	private const SPARK_FINAL_TIMEOUT = 30;

	public function __construct(
		Niwa_AI_AI_Client_Interface $openai_client,
		Niwa_AI_Auth_Provider_Interface $auth_manager,
		$memory_searcher = null,
		$humanizer = null,
		$content_store = null
	) {
		$this->openai_client   = $openai_client;
		$this->auth_manager    = $auth_manager;
		$this->memory_searcher = $memory_searcher;
		$this->humanizer       = $humanizer;
		$this->content_store   = $content_store;
	}

	/**
	 * Check if a model is Codex Spark.
	 */
	public static function is_spark_model( $model ) {
		return false !== stripos( (string) $model, 'codex-spark' );
	}

	/**
	 * Attempt generate_with_tools for Spark.
	 *
	 * If the standard tool-calling API call fails, falls back to a
	 * prompt-based approach: pre-search locally, embed results in
	 * instructions, and ask Spark for a plain completion.
	 *
	 * @param string $model       The Spark model ID.
	 * @param string|array $query User query or role-ordered conversation messages.
	 * @param string $instructions System instructions.
	 * @param array  $tools       Tool schemas (Chat Completions format).
	 * @param string $auth_mode   'oauth' or 'api_key'.
	 * @param array  $creds       Auth credentials.
	 * @param int    $timeout     Timeout in seconds.
	 *
	 * @return array {success, tool_calls, assistant_message, answer, error, _spark_fallback}
	 */
	public function generate_with_tools( $model, $query, $instructions, $tools, $auth_mode, $creds, $timeout ) {
		$timeout = max( $timeout, self::SPARK_TOOL_TIMEOUT );

		// Attempt standard tool calling with Spark.
		$result = $this->openai_client->generate_with_tools(
			$model,
			$query,
			$instructions,
			$tools,
			$auth_mode,
			$creds,
			$timeout
		);

		if ( ! empty( $result['success'] ) ) {
			// Spark responded successfully.
			// Check if it returned valid tool calls or a direct answer.
			if ( ! empty( $result['tool_calls'] ) ) {
				// Validate and repair tool call arguments if malformed.
				$result['tool_calls'] = $this->repair_tool_calls( $result['tool_calls'] );
			}
			return $result;
		}

		// Tool calling failed — log and use prompt-based fallback.
		$this->log_spark_fallback(
			'generate_with_tools_failed',
			$model,
			isset( $result['error'] ) ? (string) $result['error'] : 'unknown'
		);

		return $this->prompt_based_fallback( $model, $query, $instructions, $auth_mode, $creds );
	}

	/**
	 * Run a fail-closed required-tool round while preserving Spark argument repair.
	 * Prompt fallback is intentionally not used here because it cannot provide the
	 * complete constraint contract required for product eligibility.
	 */
	public function chat_complete_required( $model, $messages, $auth_mode, $creds, $tools, $timeout ) {
		$result = $this->openai_client->chat_complete(
			$model,
			$messages,
			$auth_mode,
			$creds,
			max( $timeout, self::SPARK_TOOL_TIMEOUT ),
			$tools,
			'required'
		);
		if ( ! empty( $result['success'] ) && ! empty( $result['tool_calls'] ) ) {
			$result['tool_calls'] = $this->repair_tool_calls( $result['tool_calls'] );
		}
		return $result;
	}

	/**
	 * Second-round API call for Spark.
	 *
	 * Sends the conversation (with function_call + function_call_output items)
	 * back to Spark. Always includes tools definitions even when tool_choice
	 * is 'none' to prevent Codex backend validation errors.
	 *
	 * @param string $model      The Spark model ID.
	 * @param array  $messages   Full message chain (system, user, assistant+tools, tool results).
	 * @param string $auth_mode  'oauth' or 'api_key'.
	 * @param array  $creds      Auth credentials.
	 * @param array  $tools      Tool schemas.
	 * @param int    $timeout    Timeout in seconds.
	 *
	 * @return array {success, answer, error, _spark_fallback}
	 */
	public function chat_complete_final( $model, $messages, $auth_mode, $creds, $tools, $timeout ) {
		$timeout = max( $timeout, self::SPARK_FINAL_TIMEOUT );

		// Use 'none' tool_choice but INCLUDE tools definitions.
		// Codex backend may reject function_call items in input without
		// a matching tools definition.
		$result = $this->openai_client->chat_complete(
			$model,
			$messages,
			$auth_mode,
			$creds,
			$timeout,
			$tools,
			'none'
		);

		if ( ! empty( $result['success'] ) ) {
			$answer = isset( $result['answer'] ) ? trim( (string) $result['answer'] ) : '';

			// Codex Spark often returns empty answer on the second call of tool-calling
			// conversations (known issue with the OAuth/ChatGPT backend). When this
			// happens, retry with simplified messages: embed tool results as plain text
			// in the conversation instead of using function_call/function_call_output items.
			if ( '' === $answer ) {
				$this->log_spark_fallback(
					'chat_complete_final_empty_answer_retrying_simplified',
					$model,
					'answer_len=0, retrying with simplified messages'
				);

				$simplified = $this->simplify_messages_for_spark( $messages );

				$retry = $this->openai_client->chat_complete(
					$model,
					$simplified,
					$auth_mode,
					$creds,
					$timeout,
					null,
					null
				);

				if ( ! empty( $retry['success'] ) ) {
					$retry_answer = isset( $retry['answer'] ) ? trim( (string) $retry['answer'] ) : '';
					if ( '' !== $retry_answer ) {
						return $retry;
					}
				}

				// Even simplified retry returned empty — log and return original result.
				if ( function_exists( 'error_log' ) ) {
					error_log( '[Niwa Spark] simplified retry returned an empty model response.' );
				}
			}

			return $result;
		}

		// If the second call with tools+none fails entirely, retry without function_call
		// items in the conversation — just system + user + tool results as text.
		$this->log_spark_fallback(
			'chat_complete_final_failed',
			$model,
			isset( $result['error'] ) ? (string) $result['error'] : 'unknown'
		);

		$simplified = $this->simplify_messages_for_spark( $messages );

		return $this->openai_client->chat_complete(
			$model,
			$simplified,
			$auth_mode,
			$creds,
			$timeout,
			null,
			null
		);
	}

	/**
	 * Prompt-based fallback: pre-search locally and embed results in the prompt.
	 *
	 * Used when Spark's tool calling API fails entirely.
	 * This still uses Spark for generation — never falls back to another model.
	 */
	private function prompt_based_fallback( $model, $query, $instructions, $auth_mode, $creds ) {
		if ( is_array( $query ) ) {
			$current_query = '';
			foreach ( array_reverse( $query ) as $message ) {
				if ( is_array( $message ) && 'user' === (string) ( $message['role'] ?? '' ) ) {
					$current_query = trim( (string) ( $message['content'] ?? '' ) );
					break;
				}
			}
			$query = $current_query;
		}

		if ( ! $this->memory_searcher ) {
			return array(
				'success'        => false,
				'error'          => 'Spark tool calling failed and no memory searcher available for fallback.',
				'answer'         => '',
				'_spark_fallback' => 'no_searcher',
			);
		}

		// Pre-search locally.
		$rows    = $this->memory_searcher->search( $query, 6, array() );
		$results = $this->memory_searcher->build_tool_results( $rows, $query );
		if ( class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
			$product_results = array();
			$other_results   = array();
			foreach ( $results as $search_result ) {
				if ( is_array( $search_result ) && ( ! isset( $search_result['type'] ) || 'product' === $search_result['type'] ) ) {
					$product_results[] = $search_result;
				} else {
					$other_results[] = $search_result;
				}
			}
			$classified = Niwa_AI_Frontend_Product_Query_Plan::classify_candidates( $product_results, array() );
			$results    = array_merge( $classified, $other_results );
		}

		if ( empty( $results ) ) {
			// Even local search found nothing — let Spark answer without context.
			$result = $this->openai_client->generate(
				$model,
				$query,
				$instructions,
				$auth_mode,
				$creds,
				self::SPARK_FINAL_TIMEOUT
			);

			$result['_spark_fallback'] = 'no_results';
			return $result;
		}

		// Build context block from search results.
		$context_block = $this->build_context_from_results( $results );

		// Append search results to instructions.
		$enhanced_instructions = $instructions
			. "\n\n[MEMORY_SEARCH_RESULTS]\n"
			. "The following are results from a memory search for the user's query. "
			. "Use these results to answer the user's question.\n\n"
			. $context_block
			. "\n[/MEMORY_SEARCH_RESULTS]";

		$result = $this->openai_client->generate(
			$model,
			$query,
			$enhanced_instructions,
			$auth_mode,
			$creds,
			self::SPARK_FINAL_TIMEOUT
		);

		if ( ! empty( $result['success'] ) ) {
			// Return as if tool calling succeeded — with synthesized card IDs.
			$card_ids = array();
			foreach ( $results as $r ) {
				if ( ! empty( $r['post_id'] ) && ( ! isset( $r['type'] ) || 'product' === $r['type'] ) && ( ! array_key_exists( 'card_eligible', $r ) || ! empty( $r['card_eligible'] ) ) ) {
					$card_ids[] = intval( $r['post_id'] );
				}
			}

			return array(
				'success'           => true,
				'tool_calls'        => array(),
				'assistant_message' => array(),
				'answer'            => isset( $result['answer'] ) ? (string) $result['answer'] : '',
				'_spark_fallback'   => 'prompt_based',
				'_fallback_rows'    => $rows,
				'_fallback_results' => $results,
				'_fallback_card_ids' => $card_ids,
			);
		}

		$result['_spark_fallback'] = 'prompt_based_failed';
		return $result;
	}

	/**
	 * Build a text context block from memory search results.
	 */
	private function build_context_from_results( $results ) {
		$blocks = array();
		foreach ( $results as $r ) {
			$lines = array();
			if ( ! empty( $r['post_id'] ) ) {
				$lines[] = 'ID: ' . $r['post_id'];
			}
			if ( ! empty( $r['title'] ) ) {
				$lines[] = 'Name: ' . $r['title'];
			}
			if ( ! empty( $r['type'] ) ) {
				$lines[] = 'Type: ' . $r['type'];
			}
			if ( ! empty( $r['price'] ) ) {
				$lines[] = 'Price: ' . $r['price'];
			}
			if ( ! empty( $r['sale_price'] ) ) {
				$lines[] = 'Sale price: ' . $r['sale_price'];
			}
			if ( ! empty( $r['match_class'] ) ) {
				$lines[] = 'Match class: ' . $r['match_class'];
			}
			if ( ! empty( $r['disclosure_required'] ) ) {
				$lines[] = 'Disclosure required: yes; do not present this result as an exact match.';
			}
			if ( ! empty( $r['stock_status'] ) ) {
				$lines[] = 'Stock: ' . $r['stock_status'];
			}
			if ( ! empty( $r['categories'] ) ) {
				$lines[] = 'Categories: ' . $r['categories'];
			}
			if ( ! empty( $r['description'] ) ) {
				$desc = mb_substr( (string) $r['description'], 0, 500 );
				$lines[] = 'Description: ' . $desc;
			}
			if ( ! empty( $r['url'] ) ) {
				$lines[] = 'URL: ' . $r['url'];
			}
			if ( ! empty( $lines ) ) {
				$blocks[] = implode( "\n", $lines );
			}
		}

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

	/**
	 * Repair malformed tool call arguments from Spark.
	 *
	 * Spark sometimes produces:
	 * - Truncated JSON
	 * - Phantom parameters not in the schema
	 * - Missing required fields
	 *
	 * This method attempts to fix common issues.
	 */
	private function repair_tool_calls( $tool_calls ) {
		if ( ! is_array( $tool_calls ) ) {
			return array();
		}

		$repaired = array();
		foreach ( $tool_calls as $call ) {
			if ( ! is_array( $call ) || empty( $call['name'] ) ) {
				continue;
			}

			$args_raw = isset( $call['arguments'] ) ? (string) $call['arguments'] : '{}';
			$args     = json_decode( $args_raw, true );

			if ( null === $args ) {
				// Try to fix common JSON issues.
				$args = $this->try_fix_json( $args_raw );
			}

			if ( null === $args ) {
				// Complete failure — use sensible defaults.
				$args = $this->get_default_args_for_tool( $call['name'] );
			}

			$call['arguments'] = is_array( $args ) ? wp_json_encode( $args ) : $args_raw;
			$repaired[]        = $call;
		}

		return $repaired;
	}

	/**
	 * Try to fix common JSON malformations from Spark.
	 */
	private function try_fix_json( $json_string ) {
		$json_string = trim( (string) $json_string );

		if ( '' === $json_string ) {
			return array();
		}

		// Try adding missing closing braces/brackets.
		$open_braces   = substr_count( $json_string, '{' ) - substr_count( $json_string, '}' );
		$open_brackets = substr_count( $json_string, '[' ) - substr_count( $json_string, ']' );

		$fixed = $json_string;
		if ( $open_brackets > 0 ) {
			$fixed .= str_repeat( ']', $open_brackets );
		}
		if ( $open_braces > 0 ) {
			$fixed .= str_repeat( '}', $open_braces );
		}

		$result = json_decode( $fixed, true );
		if ( is_array( $result ) ) {
			return $result;
		}

		// Try removing trailing comma before closing brace.
		$fixed = preg_replace( '/,\s*([}\]])/', '$1', $fixed );
		$result = json_decode( $fixed, true );
		if ( is_array( $result ) ) {
			return $result;
		}

		return null;
	}

	/**
	 * Get sensible default arguments for known tools when Spark returns garbage.
	 */
	private function get_default_args_for_tool( $tool_name ) {
		if ( 'memory_search' === $tool_name ) {
			return array( 'query' => '', 'scope' => 'all', 'limit' => 10 );
		}
		if ( 'memory_get' === $tool_name ) {
			return array( 'post_id' => 0 );
		}
		if ( 'skill_get' === $tool_name ) {
			return array( 'slug' => '' );
		}

		return array();
	}

	/**
	 * Simplify messages for Spark — remove function_call items and merge
	 * tool results into a single text context.
	 *
	 * Used when the standard message format with function_call + function_call_output
	 * fails for Spark.
	 */
	private function simplify_messages_for_spark( $messages ) {
		$simplified   = array();
		$tool_context = '';

		foreach ( (array) $messages as $msg ) {
			if ( ! is_array( $msg ) ) {
				continue;
			}

			$role = isset( $msg['role'] ) ? strtolower( trim( (string) $msg['role'] ) ) : 'user';

			// Keep system and user messages.
			if ( 'system' === $role || 'user' === $role ) {
				$simplified[] = $msg;
				continue;
			}

			// Convert tool results to text context.
			if ( 'tool' === $role ) {
				$content = isset( $msg['content'] ) ? (string) $msg['content'] : '';
				if ( '' !== $content ) {
					$tool_context .= "\n" . $content;
				}
				continue;
			}

			// Skip assistant messages with tool_calls (they reference tools
			// that we're removing from the conversation).
			if ( 'assistant' === $role && ! empty( $msg['tool_calls'] ) ) {
				continue;
			}

			// Keep other assistant messages.
			if ( 'assistant' === $role ) {
				$simplified[] = $msg;
			}
		}

		// Inject tool results as a system message context block.
		if ( '' !== trim( $tool_context ) ) {
			$simplified[] = array(
				'role'    => 'system',
				'content' => "[SEARCH_RESULTS]\n" . trim( $tool_context ) . "\n[/SEARCH_RESULTS]\n\nUse the search results above to answer the user's question. Only recommend products that appear in the results.",
			);
		}

		return $simplified;
	}

	/**
	 * Log Spark-specific failure events.
	 */
	private function log_spark_fallback( $event, $model, $error ) {
		if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) && ! ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
			return;
		}
		$event_lc = strtolower( (string) $event );
		if ( false === strpos( $event_lc, 'fail' ) && false === strpos( $event_lc, 'error' ) ) {
			return;
		}

		error_log(
			'Niwa AI Spark Adapter: ' . $event . ' | model=' . $model . ' | error=' . $error
		);
	}
}
