<?php
/**
 * Model Info.
 *
 * Retrieves and caches LLM model context window information from models.dev API.
 * Used by Context_Pruner to determine available token budget before pruning.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://agenistrator.com/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

// Direct access prevention
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Model_Info class.
 */
class Model_Info {
	use Singleton;

	/**
	 * API endpoint for model information
	 */
	private const API_URL = 'https://models.dev/api.json';

	/**
	 * Cache transient key
	 */
	private const CACHE_KEY = 'agenistrator_models_dev_cache';

	/**
	 * Cache TTL in seconds (24 hours)
	 */
	private const CACHE_TTL = 86400;

	/**
	 * Default context window when model not found (tokens)
	 */
	public const DEFAULT_CONTEXT_WINDOW = 16000;

	/**
	 * Default max output tokens
	 */
	public const DEFAULT_MAX_OUTPUT = 4000;



	/**
	 * Minimum available input tokens to prevent system failures
	 */
	public const MINIMUM_AVAILABLE_INPUT = 1000;

	/**
	 * Maximum fraction of context window to reserve for output (0.5 = 50%)
	 */
	private const MAX_OUTPUT_RESERVE_RATIO = 0.5;

	/**
	 * In-memory cache of raw API data
	 * @var array|null
	 */
	private $models_cache = null;

	/**
	 * Sorted model IDs per provider for efficient prefix matching (longest first)
	 * @var array
	 */
	private $sorted_ids_cache = array();

	/**
	 * Get available input tokens (context window minus output reserve, capped by rate limit)
	 *
	 * First tries to get limits from the provider (e.g., OpenRouter API provides this).
	 * Falls back to models.dev for providers that don't supply limits (OpenAI, Anthropic, Ollama Cloud).
	 *
	 * The output reserve is capped at MAX_OUTPUT_RESERVE_RATIO of the context window
	 * because models.dev's "output limit" represents maximum possible output, not a
	 * fixed reservation. Without this cap, models where output >= context would return 0.
	 *
	 * @param LLM_Chat $chat_model The chat model instance
	 * @return int Available input tokens (minimum MINIMUM_AVAILABLE_INPUT)
	 * @since     1.0.0
	 */
	public function get_available_input( LLM_Chat $chat_model ) {
		$provider = $chat_model->get_provider();
		$model_id = $chat_model->get_model_id();

		// Try provider-supplied limits first (authoritative for OpenRouter)
		$limits         = $provider->get_model_limits( $model_id );
		$context_window = $limits['context_length'];
		$output_limit   = $limits['max_output_tokens'];

		// Fall back to models.dev for missing values
		if ( null === $context_window || null === $output_limit ) {
			$provider_key   = $provider->get_models_dev_key();
			$model_data     = $this->find_model( $provider_key, $model_id );
			$context_window = $context_window ?? $model_data['limit']['context'] ?? $this->get_default_context_window();
			$output_limit   = $output_limit ?? $model_data['limit']['output'] ?? $this->get_default_max_output();
		}

		// Cap output reserve to prevent 0 available input when output >= context
		$max_reserve    = (int) ( $context_window * self::MAX_OUTPUT_RESERVE_RATIO );
		$output_reserve = min( $output_limit, $max_reserve );
		$model_limit    = $context_window - $output_reserve;

		// Apply provider rate limit cap if configured (null = no limit)
		$tpm_limit = $provider->get_max_tokens_per_minute();
		if ( null !== $tpm_limit ) {
			$model_limit = min( $model_limit, $tpm_limit );
		}

		return max( $model_limit, self::MINIMUM_AVAILABLE_INPUT );
	}

	/**
	 * Get default context window size from settings or fallback
	 *
	 * @return int Default context window size
	 * @since     1.0.0
	 */
	public function get_default_context_window() {
		return (int) Settings::get_instance()->get_config( 'default_context_window', self::DEFAULT_CONTEXT_WINDOW );
	}

	/**
	 * Get default max output tokens from settings or fallback
	 *
	 * @return int Default max output tokens
	 * @since     1.0.0
	 */
	public function get_default_max_output() {
		return (int) Settings::get_instance()->get_config( 'default_max_output_tokens', self::DEFAULT_MAX_OUTPUT );
	}

	/**
	 * Get model display name from models.dev
	 *
	 * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic')
	 * @param string $model_id The model ID to look up
	 * @return string Display name or model_id if not found
	 * @since     1.0.0
	 */
	public function get_display_name( $provider_key, $model_id ) {
		$model_data = $this->find_model( $provider_key, $model_id );
		return $model_data['name'] ?? $model_id;
	}

	/**
	 * Check if a model is compatible with chat (text input, text output, tool calling)
	 *
	 * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic')
	 * @param string $model_id The model ID to check
	 * @return bool|null True if compatible, false if not, null if model not found in models.dev
	 * @since     1.0.0
	 */
	public function is_chat_compatible( $provider_key, $model_id ) {
		$model_data = $this->find_model( $provider_key, $model_id );
		if ( null === $model_data ) {
			return null;
		}

		$input_modalities  = $model_data['modalities']['input'] ?? array();
		$output_modalities = $model_data['modalities']['output'] ?? array();
		$tool_call         = $model_data['tool_call'] ?? false;

		return in_array( 'text', $input_modalities, true )
			&& in_array( 'text', $output_modalities, true )
			&& true === $tool_call;
	}

	/**
	 * Check if a model supports the temperature sampling parameter
	 *
	 * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic')
	 * @param string $model_id The model ID to check
	 * @return bool|null True if supported, false if not, null if model not found in models.dev
	 * @since     1.0.1
	 */
	public function supports_temperature( $provider_key, $model_id ) {
		$model_data = $this->find_model( $provider_key, $model_id );
		if ( null === $model_data ) {
			return null;
		}

		return true === ( $model_data['temperature'] ?? false );
	}

	/**
	 * Find model data by provider key and model ID
	 *
	 * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic')
	 * @param string $model_id The model ID to find
	 * @return array|null Model data or null if not found
	 */
	private function find_model( $provider_key, $model_id ) {
		if ( empty( $provider_key ) || empty( $model_id ) ) {
			return null;
		}

		$data   = $this->get_models_data();
		$models = $data[ $provider_key ]['models'] ?? null;
		if ( ! is_array( $models ) ) {
			return null;
		}

		// direct lookup
		if ( isset( $models[ $model_id ] ) ) {
			return $models[ $model_id ];
		}

		// Fuzzy matching for versioned model IDs (e.g., claude-3-haiku-20240307 → claude-3-haiku)
		foreach ( $this->get_sorted_model_ids( $provider_key, $models ) as $id ) {
			if ( strncmp( $model_id, $id, strlen( $id ) ) === 0 || strncmp( $id, $model_id, strlen( $model_id ) ) === 0 ) {
				return $models[ $id ];
			}
		}

		return null;
	}

	/**
	 * Get sorted model IDs for a provider (longest first for best prefix matching)
	 *
	 * @param string $provider_key The provider key
	 * @param array $models The models array
	 * @return array Sorted model IDs
	 */
	private function get_sorted_model_ids( $provider_key, $models ) {
		if ( ! isset( $this->sorted_ids_cache[ $provider_key ] ) ) {
			$ids = array_keys( $models );
			usort( $ids, fn( $a, $b ) => strlen( $b ) - strlen( $a ) );
			$this->sorted_ids_cache[ $provider_key ] = $ids;
		}
		return $this->sorted_ids_cache[ $provider_key ];
	}

	/**
	 * Get models data from cache or API
	 *
	 * @return array Raw models data indexed by provider
	 */
	private function get_models_data() {
		if ( null !== $this->models_cache ) {
			return $this->models_cache;
		}

		$cached_json = get_transient( self::CACHE_KEY );
		if ( is_string( $cached_json ) ) {
			$cached = json_decode( $cached_json, true );
			if ( is_array( $cached ) ) {
				$this->models_cache = $cached;
				return $cached;
			}
		}

		$data = $this->fetch_from_api();
		if ( ! empty( $data ) ) {
			set_transient( self::CACHE_KEY, wp_json_encode( $data ), self::CACHE_TTL );
			$this->models_cache = $data;
			return $data;
		}

		return array();
	}

	/**
	 * Fetch model data from models.dev API
	 *
	 * @return array Models data or empty array on failure
	 */
	private function fetch_from_api() {
		$response = wp_remote_get(
			self::API_URL,
			array(
				'timeout'    => 10,
				'user-agent' => 'Agenistrator/' . AGENISTRATOR_VERSION,
				'headers'    => array( 'Accept' => 'application/json' ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return array();
		}

		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );

		return is_array( $data ) ? $data : array();
	}

	/**
	 * Clear the cached model data
	 * @since     1.0.0
	 */
	public function clear_cache() {
		$this->models_cache     = null;
		$this->sorted_ids_cache = array();
		delete_transient( self::CACHE_KEY );
	}
}
