<?php

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

/**
 * Message validator and sanitizer for the Niwa AI agent loop.
 *
 * Ensures that the conversation messages array sent to the LLM API is
 * well-formed and compliant with provider constraints (role alternation,
 * tool-call / tool-result pairing, no empty messages, etc.).
 *
 * PHP equivalent of OpenClaw's `validateAnthropicTurns()` and related
 * message-sanitization logic, adapted for the OpenAI chat-completions
 * format used throughout Niwa AI.
 *
 * @since 1.0.0
 */
class Niwa_AI_Message_Validator {

	/**
	 * Main entry point — validate and sanitize an entire messages array.
	 *
	 * Applies every sanitization step in a deterministic order:
	 *   1. Remove empty messages (no content).
	 *   2. Ensure proper role alternation.
	 *   3. Strip orphan tool_use blocks (no matching tool result).
	 *   4. Validate tool-result ordering (orphan tool results removed).
	 *   5. Ensure the first message after system is from the user.
	 *   6. Ensure the conversation does not end with a system message.
	 *
	 * @param array  $messages Array of message arrays (OpenAI chat format).
	 * @param string $provider Provider identifier — 'openai' (default) or 'anthropic'.
	 *
	 * @return array Sanitized messages array, safe to send to the API.
	 */
	public function validate_and_sanitize( $messages, $provider = 'openai' ) {
		if ( ! is_array( $messages ) ) {
			$messages = array();
		}

		// 1. Remove empty messages.
		$messages = $this->remove_empty_messages( $messages );

		// 2. Ensure proper role alternation (merges consecutive same-role).
		$messages = $this->ensure_role_alternation( $messages );

		// 3. Strip assistant tool_calls that have no matching tool result.
		$messages = $this->strip_orphan_tool_blocks( $messages );

		// 4. Remove tool results whose tool_call_id has no matching assistant tool_call.
		$messages = $this->validate_tool_result_ordering( $messages );

		// 5. Ensure the first non-system message is from the user.
		$messages = $this->ensure_first_user_message( $messages );

		// 6. Ensure conversation does not end with a system message.
		$messages = $this->ensure_no_trailing_system_message( $messages );

		return array_values( $messages );
	}

	/**
	 * Ensure proper role alternation across the conversation.
	 *
	 * OpenAI requires that messages follow sensible ordering:
	 *   - After a 'system' message, the next should be 'user'.
	 *   - 'tool' messages must follow an 'assistant' message that had tool_calls.
	 *   - Two consecutive 'assistant' messages are merged.
	 *   - Two consecutive 'user' messages are merged.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Fixed messages array with proper role alternation.
	 */
	public function ensure_role_alternation( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		// Merge consecutive assistant messages.
		$messages = $this->merge_consecutive_messages( $messages, 'assistant' );

		// Merge consecutive user messages.
		$messages = $this->merge_consecutive_messages( $messages, 'user' );

		// Ensure that after every 'system' message the next non-system message is 'user'.
		$fixed = array();
		$count = count( $messages );

		for ( $i = 0; $i < $count; $i++ ) {
			$msg = $messages[ $i ];
			$fixed[] = $msg;

			if ( 'system' === $msg['role'] ) {
				// Peek at the next message — if it is not 'user' and not 'system', inject one.
				$next_index = $i + 1;
				if ( $next_index < $count && 'user' !== $messages[ $next_index ]['role'] && 'system' !== $messages[ $next_index ]['role'] ) {
					$fixed[] = array(
						'role'    => 'user',
						'content' => 'Continue.',
					);
				}
			}
		}

		return array_values( $fixed );
	}

	/**
	 * Strip orphan tool_call blocks from assistant messages.
	 *
	 * For every assistant message that contains tool_calls, each individual
	 * tool_call is checked against the rest of the conversation: if no 'tool'
	 * message with a matching `tool_call_id` exists later in the array, that
	 * tool_call is removed from the assistant message.
	 *
	 * If an assistant message ends up with no content and no tool_calls after
	 * stripping, it is removed entirely.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Cleaned messages array without orphan tool_call blocks.
	 */
	public function strip_orphan_tool_blocks( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		// Build a set of all tool_call_ids present in 'tool' role messages.
		$available_result_ids = array();
		foreach ( $messages as $msg ) {
			if ( isset( $msg['role'] ) && 'tool' === $msg['role'] && ! empty( $msg['tool_call_id'] ) ) {
				$available_result_ids[ (string) $msg['tool_call_id'] ] = true;
			}
		}

		$cleaned = array();

		foreach ( $messages as $msg ) {
			// Only inspect assistant messages that carry tool_calls.
			if ( 'assistant' === ( isset( $msg['role'] ) ? $msg['role'] : '' ) && ! empty( $msg['tool_calls'] ) && is_array( $msg['tool_calls'] ) ) {
				$kept_calls = array();

				foreach ( $msg['tool_calls'] as $tool_call ) {
					$call_id = isset( $tool_call['id'] ) ? (string) $tool_call['id'] : '';
					if ( '' !== $call_id && isset( $available_result_ids[ $call_id ] ) ) {
						$kept_calls[] = $tool_call;
					}
				}

				if ( ! empty( $kept_calls ) ) {
					$msg['tool_calls'] = $kept_calls;
					$cleaned[] = $msg;
				} elseif ( $this->message_has_content( $msg ) ) {
					// Keep the message but strip the tool_calls key.
					unset( $msg['tool_calls'] );
					$cleaned[] = $msg;
				}
				// If no content and no tool_calls remain, the message is dropped.

				continue;
			}

			$cleaned[] = $msg;
		}

		return array_values( $cleaned );
	}

	/**
	 * Merge consecutive messages of the same role.
	 *
	 * When two or more consecutive messages share the given `$role`, they are
	 * collapsed into a single message:
	 *   - Text content is concatenated with "\n\n".
	 *   - tool_calls arrays are combined.
	 *
	 * @param array  $messages Messages array.
	 * @param string $role     The role to merge (e.g. 'user', 'assistant').
	 *
	 * @return array Messages array with consecutive same-role messages merged.
	 */
	public function merge_consecutive_messages( $messages, $role ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		$role   = (string) $role;
		$merged = array();
		$buffer = null;

		foreach ( $messages as $msg ) {
			$msg_role = isset( $msg['role'] ) ? (string) $msg['role'] : '';

			if ( $msg_role === $role ) {
				if ( null === $buffer ) {
					$buffer = $msg;
				} else {
					$buffer = $this->combine_two_messages( $buffer, $msg );
				}
			} else {
				if ( null !== $buffer ) {
					$merged[] = $buffer;
					$buffer   = null;
				}
				$merged[] = $msg;
			}
		}

		// Flush remaining buffer.
		if ( null !== $buffer ) {
			$merged[] = $buffer;
		}

		return array_values( $merged );
	}

	/**
	 * Validate tool-result ordering.
	 *
	 * Every 'tool' role message must appear after an 'assistant' message
	 * that contains a tool_call with a matching `tool_call_id`.  Orphan
	 * tool results — those whose `tool_call_id` does not match any
	 * preceding assistant tool_call — are removed.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Cleaned messages array with orphan tool results removed.
	 */
	public function validate_tool_result_ordering( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		// Collect all tool_call IDs from assistant messages, keyed by their position.
		$known_call_ids = array();
		foreach ( $messages as $msg ) {
			if ( 'assistant' === ( isset( $msg['role'] ) ? $msg['role'] : '' ) && ! empty( $msg['tool_calls'] ) && is_array( $msg['tool_calls'] ) ) {
				foreach ( $msg['tool_calls'] as $tc ) {
					if ( ! empty( $tc['id'] ) ) {
						$known_call_ids[ (string) $tc['id'] ] = true;
					}
				}
			}
		}

		$cleaned = array();

		foreach ( $messages as $msg ) {
			if ( 'tool' === ( isset( $msg['role'] ) ? $msg['role'] : '' ) ) {
				$call_id = isset( $msg['tool_call_id'] ) ? (string) $msg['tool_call_id'] : '';
				if ( '' === $call_id || ! isset( $known_call_ids[ $call_id ] ) ) {
					// Orphan tool result — skip it.
					continue;
				}
			}

			$cleaned[] = $msg;
		}

		return array_values( $cleaned );
	}

	/**
	 * Ensure the first non-system message is from the user.
	 *
	 * If the messages array starts with an 'assistant' or 'tool' message
	 * (after any leading 'system' messages), a minimal user message is
	 * injected so the API receives a valid opening turn.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Messages array, possibly with an injected user message.
	 */
	public function ensure_first_user_message( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		// Find the index of the first non-system message.
		$insert_index = 0;
		foreach ( $messages as $i => $msg ) {
			if ( 'system' === ( isset( $msg['role'] ) ? $msg['role'] : '' ) ) {
				$insert_index = $i + 1;
				continue;
			}
			break;
		}

		// If there's already a user message at the expected position, no work needed.
		if ( isset( $messages[ $insert_index ] ) && 'user' === $messages[ $insert_index ]['role'] ) {
			return $messages;
		}

		// If every message is a system message, nothing to inject before.
		if ( $insert_index >= count( $messages ) ) {
			return $messages;
		}

		// Inject a minimal user message.
		$injected = array(
			'role'    => 'user',
			'content' => 'Continue.',
		);

		array_splice( $messages, $insert_index, 0, array( $injected ) );

		return array_values( $messages );
	}

	/**
	 * Remove empty messages from the array.
	 *
	 * A message is considered empty when its content is an empty string,
	 * null, or an empty array.
	 *
	 * Exception: 'assistant' messages that carry `tool_calls` are kept
	 * even when their content is empty or null, because the tool_calls
	 * themselves constitute meaningful payload.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Filtered messages array with empty messages removed.
	 */
	public function remove_empty_messages( $messages ) {
		if ( ! is_array( $messages ) ) {
			return array();
		}

		$filtered = array();

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

			// Assistant messages with tool_calls are always kept.
			if ( 'assistant' === ( isset( $msg['role'] ) ? $msg['role'] : '' ) && ! empty( $msg['tool_calls'] ) && is_array( $msg['tool_calls'] ) ) {
				$filtered[] = $msg;
				continue;
			}

			// Keep the message only if it has non-empty content.
			if ( $this->message_has_content( $msg ) ) {
				$filtered[] = $msg;
			}
		}

		return array_values( $filtered );
	}

	/**
	 * Check whether a message sequence is valid for the API.
	 *
	 * Performs the following checks:
	 *   - The array is non-empty.
	 *   - The first non-system message is 'user'.
	 *   - Every 'tool' message has a matching assistant tool_call.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return bool True when the sequence is valid, false otherwise.
	 */
	public static function is_valid_message_sequence( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return false;
		}

		// Collect known tool_call IDs from assistant messages.
		$known_call_ids = array();
		foreach ( $messages as $msg ) {
			if ( ! is_array( $msg ) || ! isset( $msg['role'] ) ) {
				return false;
			}

			if ( 'assistant' === $msg['role'] && ! empty( $msg['tool_calls'] ) && is_array( $msg['tool_calls'] ) ) {
				foreach ( $msg['tool_calls'] as $tc ) {
					if ( ! empty( $tc['id'] ) ) {
						$known_call_ids[ (string) $tc['id'] ] = true;
					}
				}
			}
		}

		// Check that the first non-system message is 'user'.
		$found_first_non_system = false;
		foreach ( $messages as $msg ) {
			if ( 'system' === $msg['role'] ) {
				continue;
			}

			if ( 'user' !== $msg['role'] ) {
				return false;
			}

			$found_first_non_system = true;
			break;
		}

		// If every message is 'system', the sequence is invalid (no user turn).
		if ( ! $found_first_non_system ) {
			return false;
		}

		// Every 'tool' message must reference a known tool_call_id.
		foreach ( $messages as $msg ) {
			if ( 'tool' === $msg['role'] ) {
				$call_id = isset( $msg['tool_call_id'] ) ? (string) $msg['tool_call_id'] : '';
				if ( '' === $call_id || ! isset( $known_call_ids[ $call_id ] ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/* ------------------------------------------------------------------
	 * Private helpers
	 * ----------------------------------------------------------------*/

	/**
	 * Ensure the conversation does not end with a system message.
	 *
	 * Trailing system messages are removed because the API expects the
	 * conversation to end with a user, assistant, or tool message.
	 *
	 * @param array $messages Messages array.
	 *
	 * @return array Messages array with trailing system messages removed.
	 */
	private function ensure_no_trailing_system_message( $messages ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return array();
		}

		while ( ! empty( $messages ) ) {
			$last = end( $messages );
			if ( 'system' === ( isset( $last['role'] ) ? $last['role'] : '' ) ) {
				array_pop( $messages );
			} else {
				break;
			}
		}

		return array_values( $messages );
	}

	/**
	 * Check whether a message has non-empty content.
	 *
	 * @param array $msg Single message array.
	 *
	 * @return bool True when the message has meaningful content.
	 */
	private function message_has_content( $msg ) {
		if ( ! isset( $msg['content'] ) ) {
			return false;
		}

		$content = $msg['content'];

		if ( is_string( $content ) && '' !== $content ) {
			return true;
		}

		if ( is_array( $content ) && ! empty( $content ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Combine two messages of the same role into one.
	 *
	 * Text content is concatenated with "\n\n".  tool_calls arrays
	 * (if present on both) are merged.
	 *
	 * @param array $a First message.
	 * @param array $b Second message.
	 *
	 * @return array Combined message.
	 */
	private function combine_two_messages( $a, $b ) {
		// Merge text content.
		$content_a = isset( $a['content'] ) ? $a['content'] : '';
		$content_b = isset( $b['content'] ) ? $b['content'] : '';

		$text_a = is_string( $content_a ) ? $content_a : '';
		$text_b = is_string( $content_b ) ? $content_b : '';

		// Handle array content (multipart messages).
		if ( is_array( $content_a ) ) {
			$text_a = $this->extract_text_from_content_array( $content_a );
		}
		if ( is_array( $content_b ) ) {
			$text_b = $this->extract_text_from_content_array( $content_b );
		}

		if ( '' !== $text_a && '' !== $text_b ) {
			$a['content'] = $text_a . "\n\n" . $text_b;
		} elseif ( '' !== $text_b ) {
			$a['content'] = $text_b;
		}
		// If only $text_a has content, $a['content'] stays as-is.

		// Merge tool_calls if present.
		$calls_a = isset( $a['tool_calls'] ) && is_array( $a['tool_calls'] ) ? $a['tool_calls'] : array();
		$calls_b = isset( $b['tool_calls'] ) && is_array( $b['tool_calls'] ) ? $b['tool_calls'] : array();

		if ( ! empty( $calls_a ) || ! empty( $calls_b ) ) {
			$a['tool_calls'] = array_merge( $calls_a, $calls_b );
		}

		return $a;
	}

	/**
	 * Extract concatenated text from an OpenAI content array.
	 *
	 * Content arrays can contain mixed parts (text, image_url, etc.).
	 * This helper extracts and joins only the text parts.
	 *
	 * @param array $content_array Content array from a message.
	 *
	 * @return string Concatenated text content.
	 */
	private function extract_text_from_content_array( $content_array ) {
		$parts = array();

		foreach ( $content_array as $part ) {
			if ( is_array( $part ) && isset( $part['type'] ) && 'text' === $part['type'] && ! empty( $part['text'] ) ) {
				$parts[] = (string) $part['text'];
			}
		}

		return implode( "\n\n", $parts );
	}
}
