<?php
/**
 * Token Counter.
 *
 * Provides token counting for LLM context management using tiktoken library.
 * Uses cl100k_base encoding (GPT-4/GPT-3.5-turbo) as unified tokenizer for all
 * providers. Approximately 90-95% accurate for Claude models. Falls back to
 * character-based approximation if tiktoken fails to load.
 *
 * @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;

use guttedgarden\Tiktoken\EncoderProvider;
use guttedgarden\Tiktoken\Encoder;

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

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

	/**
	 * Encoding to use for all providers
	 */
	private const ENCODING = 'cl100k_base';

	/**
	 * Characters per token for approximation fallback
	 * Conservative average based on Anthropic (~4) and AWS Bedrock (~6) documentation
	 */
	private const CHARS_PER_TOKEN = 4;

	/**
	 * Encoder instance (lazy loaded)
	 * @var Encoder|null
	 */
	private $encoder = null;

	/**
	 * Whether tiktoken initialization failed and we're using fallback
	 * @var bool
	 */
	private $using_fallback = false;

	/**
	 * Get the encoder instance (lazy loaded)
	 * Returns null if tiktoken fails to initialize
	 *
	 * @return Encoder|null
	 */
	private function get_encoder() {
		if ( $this->using_fallback ) {
			return null;
		}

		if ( null === $this->encoder ) {
			try {
				$provider  = new EncoderProvider();
				$cache_dir = WP_CONTENT_DIR . '/cache/agenistrator/tiktoken';
				if ( ! is_dir( $cache_dir ) ) {
					wp_mkdir_p( $cache_dir );
				}
				$provider->setVocabCache( $cache_dir );
				$this->encoder = $provider->get( self::ENCODING );
			} catch ( \Throwable $e ) {
				$this->using_fallback = true;
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for tiktoken initialization failures
				error_log(
					String_Utils::replace_tags(
						__( 'Agenistrator: tiktoken initialization failed, using approximation fallback: {error}', 'agenistrator' ),
						array( 'error' => $e->getMessage() )
					)
				);
				return null;
			}
		}
		return $this->encoder;
	}

	/**
	 * Count tokens in a text string
	 *
	 * @param string $text Text to count tokens for
	 * @return int Token count
	 * @since     1.0.0
	 */
	public function count_tokens( $text ) {
		if ( empty( $text ) ) {
			return 0;
		}

		$encoder = $this->get_encoder();
		if ( null !== $encoder ) {
			return count( $encoder->encode( $text ) );
		}

		return $this->approximate_tokens( $text );
	}

	/**
	 * Approximate token count using character-based heuristic
	 * Used as fallback when tiktoken library fails to load
	 *
	 * @param string $text Text to approximate tokens for
	 * @return int Approximate token count
	 */
	private function approximate_tokens( $text ) {
		return (int) ceil( String_Utils::mb_strlen( $text ) / self::CHARS_PER_TOKEN );
	}

	/**
	 * Get encoding identifier used for token counting
	 *
	 * @return string Encoding name or 'approximation' if using fallback
	 * @since     1.0.0
	 */
	public function get_encoding() {
		return $this->get_encoder() === null ? 'approximation' : self::ENCODING;
	}

	/**
	 * Check if using fallback approximation tokenizer
	 *
	 * @return bool True if tiktoken failed and we're using approximation
	 * @since     1.0.0
	 */
	public function is_using_fallback() {
		return $this->using_fallback;
	}
}
