<?php

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

/**
 * Context window guard — monitors token usage and auto-compacts when needed.
 *
 * PHP equivalent of OpenClaw's context-window-guard.ts + compaction logic.
 * Provides token budget resolution per model, context size estimation,
 * tool result truncation, and automatic message compaction via AI
 * summarization when the context window approaches its limit.
 *
 * @since 1.0.0
 */
class Niwa_AI_Context_Window_Guard {

	/* ───────────────────────────────────────────────
	 * Constants
	 * ─────────────────────────────────────────────── */

	/** Hard limit per tool result in bytes (400 KB). */
	const TOOL_RESULT_HARD_LIMIT = 409600;

	/** Maximum fraction of context window a single tool result may consume. */
	const TOOL_RESULT_MAX_RATIO = 0.30;

	/** Trigger proactive compaction when usage exceeds this fraction. */
	const CONTEXT_WARNING_THRESHOLD = 0.70;

	/** Trigger proactive compaction for long continuation chains even on huge models. */
	const CONTEXT_ABSOLUTE_COMPACT_TOKENS = 60000;

	/** Force compaction when usage exceeds this fraction. */
	const CONTEXT_CRITICAL_THRESHOLD = 0.90;

	/** Maximum number of compaction attempts before giving up. */
	const COMPACTION_MAX_ATTEMPTS = 3;

	/* ───────────────────────────────────────────────
	 * Model token limits
	 * ─────────────────────────────────────────────── */

	/**
	 * Known context-window sizes (in tokens) keyed by model identifier.
	 *
	 * The 'default' key acts as a fallback for unknown models.
	 *
	 * @var array<string, int>
	 */
	private static $model_limits = array(
		'gpt-4o'                     => 128000,
		'gpt-4o-mini'                => 128000,
		'gpt-4.1'                    => 1048576,
		'gpt-4.1-mini'               => 1048576,
		'gpt-4.1-nano'               => 1048576,
		'o3'                         => 200000,
		'o3-mini'                    => 200000,
		'o4-mini'                    => 200000,
		'claude-sonnet-4-20250514'   => 200000,
		'claude-opus-4-20250514'     => 200000,
		'claude-haiku-3-5-20241022'  => 200000,
		'gpt-4.5-preview'            => 128000,
		'o3-pro'                     => 200000,
		'claude-sonnet-4-6'          => 200000,
		'claude-opus-4-6'            => 200000,
		'gpt-5.5'                    => 1050000,
		'gpt-5.6-terra'              => 372000,
		'gpt-5.6-sol'                => 372000,
		'gpt-5.4'                    => 1050000,
		'gpt-5.4-pro'                => 1050000,
		'gpt-5.4-mini'               => 400000,
		'gpt-5.3-codex'              => 1048576,
		'gpt-5.3-codex-spark'        => 1048576,
		'gpt-5.2-codex'              => 1048576,
		'gpt-5.1-codex'              => 1048576,
		// Fallback for unknown models.
		'default'                    => 128000,
	);

	/* ───────────────────────────────────────────────
	 * Token budget resolution
	 * ─────────────────────────────────────────────── */

	/**
	 * Resolve the context-window size (in tokens) for a given model.
	 *
	 * First attempts an exact key match, then falls back to partial
	 * matching (the model string contains a known key), and finally
	 * returns the 'default' budget.
	 *
	 * @param string $model Model identifier (e.g. "gpt-4o", "gpt-4.1-mini").
	 *
	 * @return int Token budget for the model.
	 */
	public static function resolve_token_budget( $model ) {
		// Load dynamic overrides from WordPress option (if available).
		static $dynamic_limits = null;
		if ( null === $dynamic_limits ) {
			$dynamic_limits = array();
			if ( function_exists( 'get_option' ) ) {
				$stored = get_option( 'niwa_ai_model_limits', '' );
				if ( is_string( $stored ) && '' !== $stored ) {
					$decoded = json_decode( $stored, true );
					if ( is_array( $decoded ) ) {
						$dynamic_limits = $decoded;
					}
				}
			}
		}

		// Check dynamic overrides first.
		if ( ! empty( $dynamic_limits ) ) {
			$model_lower = strtolower( (string) $model );
			foreach ( $dynamic_limits as $key => $limit ) {
				if ( strtolower( $key ) === $model_lower || false !== strpos( $model_lower, strtolower( $key ) ) ) {
					return max( 1, intval( $limit ) );
				}
			}
		}

		$model = (string) $model;

		// Exact match.
		if ( isset( self::$model_limits[ $model ] ) ) {
			return self::$model_limits[ $model ];
		}

		// Partial match — check if the model identifier contains a known key.
		foreach ( self::$model_limits as $key => $limit ) {
			if ( 'default' === $key ) {
				continue;
			}

			if ( false !== strpos( $model, $key ) ) {
				return $limit;
			}
		}

		return self::$model_limits['default'];
	}

	/* ───────────────────────────────────────────────
	 * Token estimation
	 * ─────────────────────────────────────────────── */

	/**
	 * Estimate the token count of a piece of content.
	 *
	 * Uses the ~4 characters per token heuristic. If the content is an
	 * array it is JSON-encoded first.
	 *
	 * @param string|array $content Text content or array to estimate.
	 *
	 * @return int Estimated token count (minimum 0).
	 */
	public static function estimate_tokens( $content ) {
		if ( is_array( $content ) ) {
			$content = wp_json_encode( $content );
		}

		if ( ! is_string( $content ) || '' === $content ) {
			return 0;
		}

		$chars = mb_strlen( $content, 'UTF-8' );

		return (int) max( 0, ceil( $chars / 4 ) );
	}

	/* ───────────────────────────────────────────────
	 * Context evaluation
	 * ─────────────────────────────────────────────── */

	/**
	 * Evaluate the current context-window utilization.
	 *
	 * Accepts either:
	 *   - A `Niwa_AI_Agent_Context` instance, or
	 *   - An associative array with 'messages' (array) and 'model' (string).
	 *
	 * @param Niwa_AI_Agent_Context|array $agent_context Agent context or
	 *                                                   array with messages + model.
	 *
	 * @return array {
	 *     @type int    $total_tokens    Estimated tokens currently used.
	 *     @type int    $budget          Total token budget for the model.
	 *     @type float  $usage_ratio     Usage as a fraction (0.0 - 1.0+).
	 *     @type string $status          One of 'ok', 'warning', 'critical', 'overflow'.
	 *     @type bool   $should_compact  Whether auto-compaction is recommended.
	 *     @type int    $remaining_tokens Tokens remaining before the budget is exceeded.
	 * }
	 */
	public static function evaluate_context( $agent_context ) {
		// Extract messages and model from the provided context.
		if ( $agent_context instanceof Niwa_AI_Agent_Context ) {
			$messages      = $agent_context->get_messages();
			$model         = $agent_context->get_model();
			$system_prompt = $agent_context->get_system_prompt();
			$tools         = $agent_context->get_tools();
		} else {
			$messages      = isset( $agent_context['messages'] ) && is_array( $agent_context['messages'] )
				? $agent_context['messages']
				: array();
			$model         = isset( $agent_context['model'] ) ? (string) $agent_context['model'] : '';
			$system_prompt = isset( $agent_context['system_prompt'] ) ? (string) $agent_context['system_prompt'] : '';
			$tools         = isset( $agent_context['tools'] ) && is_array( $agent_context['tools'] )
				? $agent_context['tools']
				: array();
		}

		// Resolve budget.
		$budget = self::resolve_token_budget( $model );

		// Estimate total tokens.
		$total_tokens = 0;

		// System prompt.
		$total_tokens += self::estimate_tokens( $system_prompt );

		// Messages.
		foreach ( $messages as $message ) {
			if ( isset( $message['content'] ) ) {
				$total_tokens += self::estimate_tokens( $message['content'] );
			}

			// Tool calls embedded in assistant messages.
			if ( isset( $message['tool_calls'] ) && is_array( $message['tool_calls'] ) ) {
				$total_tokens += self::estimate_tokens( $message['tool_calls'] );
			}

			// Responses API replay items can carry encrypted reasoning payloads.
			// Count them so proactive compaction matches the real prompt size.
			if ( isset( $message['response_items'] ) && is_array( $message['response_items'] ) ) {
				$total_tokens += self::estimate_tokens( $message['response_items'] );
			}
		}

		// Tool definitions.
		if ( ! empty( $tools ) ) {
			$total_tokens += self::estimate_tokens( $tools );
		}

		// Derive ratio and status.
		$usage_ratio = $budget > 0 ? ( $total_tokens / $budget ) : 1.0;
		$remaining   = max( 0, $budget - $total_tokens );

		if ( $usage_ratio >= 1.0 ) {
			$status = 'overflow';
		} elseif ( $usage_ratio >= self::CONTEXT_CRITICAL_THRESHOLD ) {
			$status = 'critical';
		} elseif ( $usage_ratio >= self::CONTEXT_WARNING_THRESHOLD || $total_tokens >= self::CONTEXT_ABSOLUTE_COMPACT_TOKENS ) {
			$status = 'warning';
		} else {
			$status = 'ok';
		}

		$should_compact = in_array( $status, array( 'warning', 'critical', 'overflow' ), true );

		return array(
			'total_tokens'     => $total_tokens,
			'budget'           => $budget,
			'usage_ratio'      => round( $usage_ratio, 4 ),
			'status'           => $status,
			'should_compact'   => $should_compact,
			'remaining_tokens' => $remaining,
		);
	}

	/* ───────────────────────────────────────────────
	 * Tool result truncation
	 * ─────────────────────────────────────────────── */

	/**
	 * Truncate a single tool result to stay within safe limits.
	 *
	 * Two limits are enforced:
	 *   1. Absolute hard limit: TOOL_RESULT_HARD_LIMIT bytes.
	 *   2. Proportional limit: TOOL_RESULT_MAX_RATIO of the model's token budget.
	 *
	 * When truncation occurs a suffix is appended indicating the original size.
	 *
	 * @param string $result_content The raw tool result string.
	 * @param string $model          Model identifier for budget resolution.
	 *
	 * @return string The (possibly truncated) tool result.
	 */
	public static function truncate_tool_result( $result_content, $model ) {
		if ( ! is_string( $result_content ) ) {
			$result_content = is_array( $result_content ) ? wp_json_encode( $result_content ) : (string) $result_content;
		}

		$original_size = strlen( $result_content );
		$budget        = self::resolve_token_budget( $model );
		$max_tokens    = (int) floor( $budget * self::TOOL_RESULT_MAX_RATIO );
		$max_bytes     = min( self::TOOL_RESULT_HARD_LIMIT, max( 1000, $max_tokens * 4 ) );
		if ( $original_size <= $max_bytes ) {
			return $result_content;
		}

		$decoded = json_decode( $result_content, true );
		if ( is_array( $decoded ) ) {
			$summary = self::summarize_oversized_json_tool_result( $decoded, $original_size );
			$encoded = function_exists( 'wp_json_encode' )
				? wp_json_encode( $summary, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
				: json_encode( $summary, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
			if ( is_string( $encoded ) && strlen( $encoded ) <= $max_bytes ) {
				return $encoded;
			}
		}

		$suffix = sprintf( '...[truncated, original size: %d bytes]', $original_size );
		return substr( $result_content, 0, max( 0, $max_bytes - strlen( $suffix ) ) ) . $suffix;
	}

	private static function summarize_oversized_json_tool_result( $decoded, $original_size ) {
		$summary = array(
			'ok'             => ! empty( $decoded['ok'] ),
			'success'        => ! empty( $decoded['success'] ),
			'truncated'      => true,
			'original_bytes' => intval( $original_size ),
			'result_keys'    => array_slice( array_keys( $decoded ), 0, 40 ),
		);

		foreach ( array( 'error', 'message', 'summary', 'status', 'scope', 'query', 'limit', 'page', 'offset', 'returned', 'maybe_more', 'continuation_required', 'next_offset', 'next_limit' ) as $key ) {
			if ( ! isset( $decoded[ $key ] ) || is_array( $decoded[ $key ] ) || is_object( $decoded[ $key ] ) ) {
				continue;
			}
			$value = (string) $decoded[ $key ];
			$summary[ $key ] = strlen( $value ) > 500 ? substr( $value, 0, 497 ) . '...' : $decoded[ $key ];
		}

		$collection_count = 0;
		foreach ( $decoded as $key => $value ) {
			if ( ! is_array( $value ) ) {
				continue;
			}
			$summary[ $key . '_count' ] = count( $value );
			$collection_count++;
			if ( $collection_count >= 40 ) {
				break;
			}
		}

		return $summary;
	}

	/* ───────────────────────────────────────────────
	 * Auto-compaction
	 * ─────────────────────────────────────────────── */

	/**
	 * Compact the message history by summarizing older messages via AI.
	 *
	 * Preserves:
	 *   - The system message.
	 *   - The immutable current turn and its preceding dialogue pair.
	 *   - The newest complete tool-call/result group.
	 *
	 * Everything in between is replaced by a single summary message
	 * generated through the AI client.
	 *
	 * @param array                       $messages      Conversation messages array.
	 * @param string                      $system_prompt System prompt text.
	 * @param string                      $model         Model identifier.
	 * @param Niwa_AI_AI_Client_Interface $ai_client     AI client instance.
	 * @param string                      $auth_mode     Authentication mode ('api_key' or 'oauth').
	 * @param array                       $creds         Credentials array.
	 * @param array                       $turn_frame    Immutable founder-turn frame.
	 *
	 * @return array The compacted messages array. On failure, returns the
	 *               original messages unchanged.
	 */
	public static function compact_messages( $messages, $system_prompt, $model, $ai_client, $auth_mode, $creds, $turn_frame = array() ) {
		if ( ! is_array( $messages ) ) {
			return $messages;
		}

		$count = count( $messages );

		// Need at least 6 messages to compact.
		if ( $count < 6 ) {
			return $messages;
		}

		$turn_frame        = is_array( $turn_frame ) ? $turn_frame : array();
		$generation        = 1;
		$messages          = array_values(
			array_filter(
				$messages,
				static function ( $message ) use ( &$generation ) {
					if ( ! is_array( $message ) || empty( $message['niwa_compaction_handoff'] ) ) {
						return true;
					}
					$generation = max( $generation, intval( $message['compaction_generation'] ?? 0 ) + 1 );
					return false;
				}
			)
		);
		$count             = count( $messages );
		$turn_objective    = trim( (string) ( $turn_frame['turn_objective'] ?? '' ) );
		$frame_id          = sanitize_key( (string) ( $turn_frame['frame_id'] ?? '' ) );
		if ( '' === $frame_id ) {
			$frame_id = 'turn_' . substr( hash( 'sha256', $turn_objective ), 0, 24 );
		}
		if ( function_exists( 'do_action' ) ) {
			do_action( 'niwa_ai_context_pre_compact', $frame_id, $generation, $count );
		}
		$preserved_indexes = array();
		$has_system        = isset( $messages[0]['role'] ) && 'system' === (string) $messages[0]['role'];
		if ( $has_system ) {
			$preserved_indexes[0] = true;
		}

		$current_user_index = -1;
		for ( $index = $count - 1; $index >= 0; $index-- ) {
			$message = isset( $messages[ $index ] ) && is_array( $messages[ $index ] ) ? $messages[ $index ] : array();
			$content = self::message_content_text( $message );
			if (
				'user' === (string) ( $message['role'] ?? '' )
				&& '' !== $content
				&& ( '' === $turn_objective || hash_equals( $turn_objective, $content ) )
			) {
				$current_user_index = $index;
				break;
			}
		}

		if ( $current_user_index >= 0 ) {
			$preserved_indexes[ $current_user_index ] = true;
			$previous_dialogue = isset( $turn_frame['previous_dialogue'] ) && is_array( $turn_frame['previous_dialogue'] )
				? array_values( $turn_frame['previous_dialogue'] )
				: array();
			$search_before = $current_user_index - 1;
			for ( $anchor = count( $previous_dialogue ) - 1; $anchor >= 0 && $search_before >= 0; $anchor-- ) {
				$expected      = is_array( $previous_dialogue[ $anchor ] ) ? $previous_dialogue[ $anchor ] : array();
				$expected_role = (string) ( $expected['role'] ?? '' );
				$expected_text = trim( (string) ( $expected['content'] ?? '' ) );
				for ( $index = $search_before; $index >= 0; $index-- ) {
					$message = isset( $messages[ $index ] ) && is_array( $messages[ $index ] ) ? $messages[ $index ] : array();
					if (
						$expected_role === (string) ( $message['role'] ?? '' )
						&& '' !== $expected_text
						&& hash_equals( $expected_text, self::message_content_text( $message ) )
					) {
						$preserved_indexes[ $index ] = true;
						$search_before = $index - 1;
						break;
					}
				}
			}
		}

		$latest_tool_group_start = -1;
		for ( $index = $count - 1; $index > $current_user_index; $index-- ) {
			if ( self::assistant_has_tool_calls( isset( $messages[ $index ] ) ? $messages[ $index ] : array() ) ) {
				$latest_tool_group_start = $index;
				break;
			}
		}
		if ( $latest_tool_group_start >= 0 ) {
			for ( $index = $latest_tool_group_start; $index < $count; $index++ ) {
				$preserved_indexes[ $index ] = true;
			}
		} else {
			for ( $index = max( 0, $count - 4 ); $index < $count; $index++ ) {
				if ( 'tool' !== (string) ( $messages[ $index ]['role'] ?? '' ) ) {
					$preserved_indexes[ $index ] = true;
				}
			}
		}

		$before_middle = array();
		$after_middle  = array();
		foreach ( $messages as $index => $message ) {
			if ( isset( $preserved_indexes[ $index ] ) ) {
				continue;
			}
			if ( $current_user_index >= 0 && $index > $current_user_index ) {
				$after_middle[] = $message;
			} else {
				$before_middle[] = $message;
			}
		}

		if ( empty( $before_middle ) && empty( $after_middle ) ) {
			return $messages;
		}

		$before_summary = empty( $before_middle )
			? array()
			: self::summarize_message_segment(
				$before_middle,
				'earlier conversation before the current founder turn',
				$model,
				$ai_client,
				$auth_mode,
				$creds
			);
		if ( null === $before_summary ) {
			return $messages;
		}

		$after_summary = empty( $after_middle )
			? array()
			: self::summarize_message_segment(
				$after_middle,
				'tool work already completed after the current founder turn',
				$model,
				$ai_client,
				$auth_mode,
				$creds
			);
		if ( null === $after_summary ) {
			return $messages;
		}

		// Reconstruct without moving post-prompt tool work ahead of the prompt.
		$compacted = array();
		if ( $has_system && isset( $preserved_indexes[0] ) ) {
			$compacted[] = $messages[0];
		}
		$additional_context = function_exists( 'apply_filters' )
			? trim( (string) apply_filters( 'niwa_ai_context_compaction_additional_context', '', $frame_id, $generation ) )
			: '';
		if ( strlen( $additional_context ) > 2000 ) {
			$additional_context = substr( $additional_context, 0, 2000 );
		}
		$handoff = array(
			'role'                  => 'system',
			'content'               => sprintf(
				"[TURN CONTINUATION HANDOFF]\nFrame: %s\nCompaction generation: %d\nContinue the same founder turn. The preserved current user message and previous dialogue are authoritative. Auto-compacted summaries are advisory. Preserved tool calls and results bind work already completed.%s",
				$frame_id,
				$generation,
				'' !== $additional_context ? "\nAdditional trusted continuation context:\n" . $additional_context : ''
			),
			'niwa_runtime_control'  => true,
			'niwa_compaction_handoff' => true,
			'compaction_generation' => $generation,
			'turn_frame_id'         => $frame_id,
		);
		if ( is_array( $handoff ) && ! empty( $handoff['content'] ) ) {
			$compacted[] = $handoff;
		}
		if ( ! empty( $before_summary ) ) {
			$compacted[] = $before_summary;
		}
		ksort( $preserved_indexes );
		foreach ( array_keys( $preserved_indexes ) as $index ) {
			if (
				( ! $has_system || 0 !== $index )
				&& ( $current_user_index < 0 || $index <= $current_user_index )
				&& isset( $messages[ $index ] )
			) {
				$compacted[] = $messages[ $index ];
			}
		}
		if ( ! empty( $after_summary ) ) {
			$compacted[] = $after_summary;
		}
		foreach ( array_keys( $preserved_indexes ) as $index ) {
			if ( $current_user_index >= 0 && $index > $current_user_index && isset( $messages[ $index ] ) ) {
				$compacted[] = $messages[ $index ];
			}
		}

		if ( function_exists( 'do_action' ) ) {
			do_action( 'niwa_ai_context_post_compact', $frame_id, $generation, $count, count( $compacted ) );
		}

		return $compacted;
	}

	private static function summarize_message_segment( $messages, $segment_label, $model, $ai_client, $auth_mode, $creds ) {
		$messages = is_array( $messages ) ? array_values( $messages ) : array();
		if ( empty( $messages ) ) {
			return array();
		}

		$segment_text = '';
		foreach ( $messages as $message ) {
			$role    = isset( $message['role'] ) ? $message['role'] : 'unknown';
			$content = '';
			if ( isset( $message['content'] ) ) {
				$content = is_string( $message['content'] )
					? $message['content']
					: wp_json_encode( $message['content'] );
			}
			if ( isset( $message['tool_calls'] ) && is_array( $message['tool_calls'] ) ) {
				$content .= "\n[Tool calls: " . wp_json_encode( $message['tool_calls'] ) . ']';
			}
			$segment_text .= sprintf( "[%s]: %s\n\n", $role, $content );
		}

		$summary_instructions = 'You are a conversation summarizer. '
			. 'Produce a concise summary of the supplied chronological segment. '
			. 'Preserve key facts, decisions, tool results, completed actions, and remaining work. '
			. 'Do not move actions to another point in the timeline. '
			. 'Do NOT add commentary — output only the summary.';
		$summary_input = sprintf(
			"Segment position: %s.\nSummarize this segment (%d messages):\n\n%s",
			(string) $segment_label,
			count( $messages ),
			$segment_text
		);

		try {
			$result = $ai_client->generate(
				$model,
				$summary_input,
				$summary_instructions,
				$auth_mode,
				$creds,
				60
			);
		} catch ( \Throwable $error ) {
			return null;
		}

		if ( empty( $result['success'] ) || empty( $result['answer'] ) ) {
			return null;
		}

		return array(
			'role'    => 'assistant',
			'content' => sprintf(
				"[Auto-compacted summary: %s; %d messages]\n\n%s",
				(string) $segment_label,
				count( $messages ),
				$result['answer']
			),
		);
	}

	private static function message_content_text( $message ) {
		if ( ! is_array( $message ) || ! array_key_exists( 'content', $message ) ) {
			return '';
		}
		if ( class_exists( 'Niwa_AI_Admin_Autonomy_Task_Integrity' ) ) {
			return Niwa_AI_Admin_Autonomy_Task_Integrity::canonicalize_content( $message['content'] );
		}
		return is_string( $message['content'] )
			? trim( $message['content'] )
			: trim( (string) wp_json_encode( $message['content'] ) );
	}

	private static function assistant_has_tool_calls( $message ) {
		if ( ! is_array( $message ) || 'assistant' !== (string) ( $message['role'] ?? '' ) ) {
			return false;
		}
		if ( ! empty( $message['tool_calls'] ) && is_array( $message['tool_calls'] ) ) {
			return true;
		}
		if ( empty( $message['response_items'] ) || ! is_array( $message['response_items'] ) ) {
			return false;
		}
		foreach ( $message['response_items'] as $item ) {
			if ( is_array( $item ) && 'function_call' === (string) ( $item['type'] ?? '' ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Attempt compaction up to COMPACTION_MAX_ATTEMPTS times.
	 *
	 * After each attempt the context is re-evaluated. If the status is
	 * still 'critical' or 'overflow', another round is attempted with
	 * the (progressively smaller) message array. An increasingly
	 * aggressive tail is kept on each retry (fewer recent messages
	 * preserved).
	 *
	 * @param array                       $messages      Conversation messages array.
	 * @param string                      $system_prompt System prompt text.
	 * @param string                      $model         Model identifier.
	 * @param Niwa_AI_AI_Client_Interface $ai_client     AI client instance.
	 * @param string                      $auth_mode     Authentication mode.
	 * @param array                       $creds         Credentials array.
	 *
	 * @return array {
	 *     @type bool  $success  Whether the context is no longer critical.
	 *     @type array $messages The (possibly compacted) messages.
	 *     @type int   $attempts Number of compaction attempts made.
	 * }
	 */
	public static function compact_with_retry( $messages, $system_prompt, $model, $ai_client, $auth_mode, $creds, $tools = array(), $turn_frame = array() ) {
		$attempts         = 0;
		$current_messages = $messages;
		$tools            = is_array( $tools ) ? $tools : array();

		while ( $attempts < self::COMPACTION_MAX_ATTEMPTS ) {
			$attempts++;

			$current_messages = self::compact_messages(
				$current_messages,
				$system_prompt,
				$model,
				$ai_client,
				$auth_mode,
				$creds,
				$turn_frame
			);

			// Re-evaluate after compaction.
			$eval = self::evaluate_context( array(
				'messages'      => $current_messages,
				'model'         => $model,
				'system_prompt' => $system_prompt,
				'tools'         => $tools,
			) );

			if ( ! $eval['should_compact'] ) {
				return array(
					'success'  => true,
					'messages' => $current_messages,
					'attempts' => $attempts,
				);
			}

			// Still critical — apply more aggressive truncation to individual
			// messages before the next compaction attempt.
			$current_messages = self::aggressively_truncate_messages( $current_messages, $model );
		}

		// Exhausted all attempts — evaluate one last time.
		$eval = self::evaluate_context( array(
			'messages'      => $current_messages,
			'model'         => $model,
			'system_prompt' => $system_prompt,
			'tools'         => $tools,
		) );

		return array(
			'success'  => ! $eval['should_compact'],
			'messages' => $current_messages,
			'attempts' => $attempts,
		);
	}

	/* ───────────────────────────────────────────────
	 * Internal helpers
	 * ─────────────────────────────────────────────── */

	/**
	 * Aggressively truncate individual message contents.
	 *
	 * Used between compaction retries to further reduce context size.
	 * Truncates any tool-role message or large assistant message to a
	 * fraction of the per-result budget.
	 *
	 * @param array  $messages Messages array.
	 * @param string $model    Model identifier.
	 *
	 * @return array Messages with large contents truncated.
	 */
	private static function aggressively_truncate_messages( $messages, $model ) {
		$budget         = self::resolve_token_budget( $model );
		$max_per_msg    = (int) floor( ( $budget * self::TOOL_RESULT_MAX_RATIO * 4 ) / 2 ); // Half the normal ratio in bytes.

		foreach ( $messages as $index => &$message ) {
			if ( isset( $message['response_items'] ) && is_array( $message['response_items'] ) ) {
				$message['response_items'] = self::strip_reasoning_response_items( $message['response_items'] );
				if ( empty( $message['response_items'] ) ) {
					unset( $message['response_items'] );
				}
			}

			if ( ! isset( $message['content'] ) || ! is_string( $message['content'] ) ) {
				continue;
			}

			$role = isset( $message['role'] ) ? $message['role'] : '';

			// Only truncate tool results and very large assistant messages.
			if ( 'tool' !== $role && 'assistant' !== $role ) {
				continue;
			}

			$length = strlen( $message['content'] );

			if ( $length > $max_per_msg ) {
				$original_size      = $length;
				$message['content'] = substr( $message['content'], 0, $max_per_msg )
					. sprintf( '...[aggressively truncated, original size: %d bytes]', $original_size );
			}
		}

		unset( $message );

		return $messages;
	}

	/**
	 * Remove bulky replay-only reasoning items while keeping function calls.
	 *
	 * @param array $response_items Responses API replay items.
	 * @return array Filtered response items.
	 */
	private static function strip_reasoning_response_items( $response_items ) {
		$kept = array();
		foreach ( $response_items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$type = isset( $item['type'] ) ? (string) $item['type'] : '';
			if ( 'reasoning' === $type ) {
				continue;
			}

			$kept[] = $item;
		}

		return $kept;
	}
}
