<?php
/**
 * String Utilities.
 *
 * Provides utility methods for string manipulation, JSON handling,
 * and template rendering.
 *
 * @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://maximum.software/
 *
 * 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' ) ) {
	return;
}

/**
 * String_Utils class.
 */
class String_Utils {

	/**
	 * Multibyte-safe strpos with fallback
	 *
	 * @param string $haystack The string to search in
	 * @param string $needle The string to search for
	 * @param int $offset The position to start searching from
	 * @return int|false The position of the needle, or false if not found
	 * @since     1.0.0
	 */
	public static function mb_strpos( $haystack, $needle, $offset = 0 ) {
		return function_exists( 'mb_strpos' ) ? mb_strpos( $haystack, $needle, $offset ) : strpos( $haystack, $needle, $offset );
	}

	/**
	 * Multibyte-safe strlen with fallback
	 *
	 * @param string $str The string to measure
	 * @return int The length of the string
	 * @since     1.0.0
	 */
	public static function mb_strlen( $str ) {
		return function_exists( 'mb_strlen' ) ? mb_strlen( $str ) : strlen( $str );
	}

	/**
	 * Multibyte-safe substr with fallback
	 *
	 * @param string $str The input string
	 * @param int $start The start position
	 * @param int|null $length The length of the substring
	 * @return string The extracted substring
	 * @since     1.0.0
	 */
	public static function mb_substr( $str, $start, $length = null ) {
		return function_exists( 'mb_substr' ) ? mb_substr( $str, $start, $length ) : substr( $str, $start, $length );
	}

	/**
	 * Multibyte-safe strtolower with fallback
	 *
	 * @param string $str The string to convert
	 * @return string The lowercase string
	 * @since     1.0.0
	 */
	public static function mb_strtolower( $str ) {
		return function_exists( 'mb_strtolower' ) ? mb_strtolower( $str ) : strtolower( $str );
	}

	/**
	 * Multibyte-safe trim using regex
	 *
	 * @param string $str The string to trim
	 * @return string The trimmed string
	 * @since     1.0.0
	 */
	public static function mb_trim( $str ) {
		return preg_replace( '/(^\s+)|(\s+$)/us', '', $str );
	}

	/**
	 * Multibyte-safe strcut with fallback
	 *
	 * Cuts a string at byte position but ensures it doesn't break multibyte characters.
	 * This is safer than substr() for UTF-8 strings.
	 *
	 * @param string $str The input string
	 * @param int $start The start position in bytes
	 * @param int|null $length The length in bytes
	 * @param string $encoding The character encoding (default: UTF-8)
	 * @return string The extracted substring
	 * @since     1.0.0
	 */
	public static function mb_strcut( $str, $start, $length = null, $encoding = 'UTF-8' ) {
		if ( function_exists( 'mb_strcut' ) ) {
			return mb_strcut( $str, $start, $length, $encoding );
		}
		// Fallback: use substr but this may break multibyte characters
		return null === $length ? substr( $str, $start ) : substr( $str, $start, $length );
	}

	/**
	 * Decode JSON string with error handling
	 *
	 * @param string $json JSON string to decode
	 * @param bool $associative When true, returns associative arrays instead of objects
	 * @return mixed Decoded value
	 * @throws \Exception If JSON is invalid
	 * @since     1.0.0
	 */
	public static function decode_json( $json, $associative = true ) {
		$data = json_decode( $json, $associative );
		if ( json_last_error() !== JSON_ERROR_NONE ) {
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception(
				self::replace_tags(
					__( 'Invalid JSON: {error}', 'agenistrator' ),
					array( 'error' => json_last_error_msg() )
				)
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
		return $data;
	}

	/**
	 * Fix empty arrays that should be objects for proper JSON encoding
	 *
	 * PHP's json_encode() encodes empty arrays as [] but APIs often expect
	 * certain fields to be objects {}. This method converts empty arrays to
	 * stdClass for paths that match the provided patterns.
	 *
	 * Path patterns use a simple matching system:
	 * - Exact path: 'tools.0.function.parameters.properties' matches exactly that path
	 * - Wildcard: '*.properties' matches 'properties' at any depth
	 * - The path is built by joining keys with '.'
	 *
	 * Only empty arrays are converted. Non-empty arrays and non-array values
	 * (e.g., booleans) are left unchanged. This allows handling fields that
	 * CAN be objects but aren't guaranteed to be (like additionalProperties).
	 *
	 * @param array $data The data array to fix
	 * @param array $object_paths Array of path patterns that should be objects when empty
	 * @return array The fixed data with empty arrays converted to stdClass where needed
	 * @since     1.0.0
	 */
	public static function fix_empty_arrays_to_objects( $data, $object_paths ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}
		return self::fix_empty_arrays_recursive( $data, '', $object_paths );
	}

	/**
	 * Recursively fix empty arrays that should be objects
	 *
	 * @param mixed $value The current value being processed
	 * @param string $current_path The current path in the data (dot-separated)
	 * @param array $object_paths Array of path patterns that should be objects
	 * @return mixed The fixed value
	 */
	private static function fix_empty_arrays_recursive( $value, $current_path, $object_paths ) {
		foreach ( $value as $key => &$child ) {
			// Build the path for this child
			$child_path = '' === $current_path ? (string) $key : $current_path . '.' . $key;

			// Only process arrays - leave non-arrays (booleans, strings, etc.) unchanged
			if ( ! is_array( $child ) ) {
				continue;
			}

			// Check if this path should be an object when empty
			if ( empty( $child ) && self::path_matches_pattern( $child_path, $key, $object_paths ) ) {
				$child = new \stdClass();
			} else {
				// Recursively process
				$child = self::fix_empty_arrays_recursive( $child, $child_path, $object_paths );
			}
		}

		return $value;
	}

	/**
	 * Check if a path matches any of the provided patterns
	 *
	 * @param string $full_path The full dot-separated path
	 * @param string $key The current key name
	 * @param array $patterns Array of patterns to match against
	 * @return bool True if the path matches any pattern
	 */
	private static function path_matches_pattern( $full_path, $key, $patterns ) {
		foreach ( $patterns as $pattern ) {
			// Check for wildcard pattern (*.key)
			if ( 0 === strpos( $pattern, '*.' ) ) {
				$pattern_key = substr( $pattern, 2 );
				if ( $key === $pattern_key ) {
					return true;
				}
			} elseif ( $full_path === $pattern ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Helper for replace_tags function
	 *
	 * @param string $str The string to wrap in curly braces
	 * @return string The string wrapped in curly braces
	 */
	private static function add_curly_braces( $str ) {
		return '{' . $str . '}';
	}

	/**
	 * Takes a string with tags and replaces tags in the string with the given values in $tags array
	 *
	 * @param string $template The string with tags in {tag} format
	 * @param array $tags Associative array of tag => value pairs
	 *                    IMPORTANT: By convention, all tag values must be pre-escaped using appropriate
	 *                    WordPress escaping functions (esc_html, esc_attr, esc_url, etc.)
	 *                    or esc_html__() for localized strings before passing to this function.
	 *                    This function does NOT perform any escaping on the values.
	 * @return string The string with tags replaced
	 * @since     1.0.0
	 */
	public static function replace_tags( $template, $tags = array() ) {
		return str_replace(
			array_map( array( __CLASS__, 'add_curly_braces' ), array_keys( $tags ) ),
			array_values( $tags ),
			$template
		);
	}

	/**
	 * Takes html template file and renders it with the given attributes
	 *
	 * @param string $template_filepath Full path to the template file
	 * @param array $attributes Associative array of attributes to replace in the template
	 *                          IMPORTANT: By convention, all attribute values must be pre-escaped using appropriate
	 *                          WordPress escaping functions (esc_html, esc_attr, esc_url, etc.)
	 *                          or esc_html__() for localized strings before passing to this function.
	 *                          This function does NOT perform any escaping on the values.
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render_file( $template_filepath, $attributes = array() ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- $template_filepath is a local filesystem path
		return self::replace_tags( file_get_contents( $template_filepath ), $attributes );
	}

	/**
	 * Renders an HTML template with the given attributes
	 *
	 * @param string $template The template name without .html extension
	 * @param array $attributes Associative array of attributes to replace in the template
	 *                          IMPORTANT: By convention, all attribute values must be pre-escaped using appropriate
	 *                          WordPress escaping functions (esc_html, esc_attr, esc_url, etc.)
	 *                          or esc_html__() for localized strings before passing to this function.
	 *                          This function does NOT perform any escaping on the values.
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render( $template, $attributes = array() ) {
		return self::render_file( plugin_dir_path( __DIR__ ) . 'admin/html/' . $template . '.html', $attributes );
	}

	/**
	 * Renders a notice with the given attributes
	 *
	 * @param string $notice_id Unique ID for the notice (for dismissible notices)
	 * @param string $type Notice type (success, warning, error)
	 * @param array $attributes Associative array of attributes
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render_notice( $notice_id, $type, $attributes = array() ) {
		if ( ! isset( $attributes['classes'] ) ) {
			$attributes['classes'] = '';
		}
		$attributes['classes'] = trim( $attributes['classes'] . ' notice-' . esc_attr( $type ) );

		if ( ! isset( $attributes['label'] ) ) {
			$attributes['label'] = esc_html__( 'Agenistrator', 'agenistrator' );
		}

		if ( $notice_id ) {
			$attributes['attributes'] = 'data-notice-id="' . esc_attr( $notice_id ) . '"';
			$attributes['classes']   .= ' is-dismissible';
		}

		return self::render( 'notice', $attributes );
	}

	/**
	 * Renders a success notice with the given attributes
	 *
	 * @param string $notice_id Unique ID for the notice
	 * @param array $attributes Associative array of attributes
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render_success_notice( $notice_id, $attributes = array() ) {
		return self::render_notice( $notice_id, 'success', $attributes );
	}

	/**
	 * Renders a warning notice with the given attributes
	 *
	 * @param string $notice_id Unique ID for the notice
	 * @param array $attributes Associative array of attributes
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render_warning_notice( $notice_id, $attributes = array() ) {
		return self::render_notice( $notice_id, 'warning', $attributes );
	}

	/**
	 * Renders an error notice with the given attributes
	 *
	 * @param string $notice_id Unique ID for the notice
	 * @param array $attributes Associative array of attributes
	 * @return string The rendered HTML
	 * @since     1.0.0
	 */
	public static function render_error_notice( $notice_id, $attributes = array() ) {
		return self::render_notice( $notice_id, 'error', $attributes );
	}

	/**
	 * Truncate text to a maximum length, preserving token boundaries and appending ellipsis
	 *
	 * Uses split_on_token_boundary to ensure truncation happens at token boundaries,
	 * preventing tokens from being cut mid-way.
	 *
	 * Note: This method works with byte count (not characters) to limit information density.
	 * Bytes are a better measure of information content than character count - for example,
	 * a Chinese character (4 bytes) carries more semantic information than an ASCII character (1 byte),
	 * but at the same time the Chinese character takes up more bytes, so, byte count is going to be roughly similar.
	 *
	 * @param string $text The text to truncate
	 * @param int $size_in_bytes Target length in bytes (actual size may be larger due to token-boundary alignment)
	 * @return string Truncated text with ellipsis
	 * @since     1.0.0
	 */
	public static function truncate_to_token_boundary_with_ellipsis( $text, $size_in_bytes ) {
		// Fixed truncation marker used in tool/LLM-facing output
		$ellipsis = '... [truncated]';

		// Use strlen for byte length comparison
		if ( strlen( $text ) <= $size_in_bytes ) {
			return $text;
		}

		// Reserve space for ellipsis (in bytes)
		$ellipsis_bytes = strlen( $ellipsis );
		$size_in_bytes -= $ellipsis_bytes;
		if ( $size_in_bytes <= 0 ) {
			return $ellipsis;
		}

		// Use split_on_token_boundary to truncate at token boundary
		$result = self::split_on_token_boundary( $text, 0, $size_in_bytes );

		return $result['content'] . $ellipsis;
	}

	/**
	 * Multibyte-safe strrpos with fallback
	 *
	 * @param string $haystack The string to search in
	 * @param string $needle The string to search for
	 * @param int $offset The position to start searching from
	 * @return int|false The position of the last occurrence, or false if not found
	 * @since     1.0.0
	 */
	public static function mb_strrpos( $haystack, $needle, $offset = 0 ) {
		return function_exists( 'mb_strrpos' ) ? mb_strrpos( $haystack, $needle, $offset ) : strrpos( $haystack, $needle, $offset );
	}

	/**
	 * Split content on token boundary
	 *
	 * This method cuts content at a specific byte offset and chunk size, but ensures
	 * the cut happens on a token boundary to prevent confusion for LLMs. It starts
	 * from the exact byte offset but has flexibility in chunk size.
	 *
	 * Algorithm:
	 * 1. Cut at the requested byte offset and chunk size
	 * 2. Count tokens in the chunk
	 * 3. Keep appending bytes until token count increases
	 * 4. This guarantees we cut on token boundaries
	 *
	 * @param string $text The text to split
	 * @param int $offset The byte offset to start from
	 * @param int $size_in_bytes Target chunk size in bytes (actual size may be larger due to token-boundary alignment)
	 * @return array Array with keys: 'content', 'total_size', 'offset', 'size', 'has_more'
	 * @since     1.0.0
	 */
	public static function split_on_token_boundary( $text, $offset, $size_in_bytes ) {
		$total_size = strlen( $text );

		// Validate offset
		if ( $offset < 0 || $offset >= $total_size ) {
			return array(
				'content'    => '',
				'total_size' => $total_size,
				'offset'     => $offset,
				'size'       => 0,
				'has_more'   => false,
			);
		}

		// Extract initial chunk - mb_strcut works with byte offsets/lengths and ensures we don't break multibyte UTF-8 characters
		$chunk      = self::mb_strcut( $text, $offset, $size_in_bytes, 'UTF-8' );
		$chunk_size = strlen( $chunk );

		// If we've reached the end, return as-is
		if ( $offset + $chunk_size >= $total_size ) {
			return array(
				'content'    => $chunk,
				'total_size' => $total_size,
				'offset'     => $offset,
				'size'       => $chunk_size,
				'has_more'   => false,
			);
		}

		// Count tokens in the initial chunk
		$token_counter       = Token_Counter::get_instance();
		$current_token_count = $token_counter->count_tokens( $chunk );

		// Keep appending bytes until token count increases
		// This ensures we cut on a token boundary
		$max_iterations = 100; // Safety limit to prevent infinite loops
		$iteration      = 0;

		while ( $iteration < $max_iterations && $offset + $chunk_size < $total_size ) {
			// Try adding one more byte
			$next_chunk       = self::mb_strcut( $text, $offset, $chunk_size + 1, 'UTF-8' );
			$next_token_count = $token_counter->count_tokens( $next_chunk );

			// If token count increased, we've crossed a token boundary
			if ( $next_token_count > $current_token_count ) {
				break;
			}

			// Token count didn't increase, keep this byte
			$chunk      = $next_chunk;
			$chunk_size = strlen( $chunk );
			++$iteration;
		}

		return array(
			'content'    => $chunk,
			'total_size' => $total_size,
			'offset'     => $offset,
			'size'       => $chunk_size,
			'has_more'   => ( $offset + $chunk_size ) < $total_size,
		);
	}
}
