<?php
/**
 * Fetch URL Tool.
 *
 * Fetch content from a remote URL using wp_remote_get(). HTML content is
 * converted to Markdown using HTMLToMarkdown for LLM context efficiency.
 *
 * @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 League\HTMLToMarkdown\HtmlConverter;

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

/**
 * Fetch_Url_Tool class.
 */
class Fetch_Url_Tool extends Tool {

	private const DEFAULT_MAX_SIZE = 10240; // 10KB

	/**
	 * Get the tool ID.
	 *
	 * @return string The tool ID.
	 * @since     1.0.0
	 */
	public function get_id() {
		return 'fetch_url';
	}

	/**
	 * Get the tool name.
	 *
	 * @return string The tool name.
	 * @since     1.0.0
	 */
	public function get_name() {
		return __( 'Fetch URL', 'agenistrator' );
	}

	/**
	 * Get the tool description.
	 *
	 * @return string The tool description.
	 * @since     1.0.0
	 */
	public function get_description() {
		return 'Fetch text-based content from a URL for reading.';
	}

	/**
	 * Get usage instructions for the LLM.
	 *
	 * @return string Instructions for using this tool.
	 * @since     1.0.0
	 */
	public function get_instructions() {
		return 'Supports HTML, JSON, XML, plain text, and similar formats. Binary content (images, PDFs, etc.) is not supported. HTML is automatically converted to Markdown for readability.';
	}

	/**
	 * Check if this tool is read-only.
	 *
	 * @return bool True if the tool only reads data.
	 * @since     1.0.0
	 */
	public function is_read_only() {
		return true;
	}

	/**
	 * Get the parameters schema for this tool.
	 *
	 * @return array Parameters schema in JSON Schema format.
	 * @since     1.0.0
	 */
	public function get_parameters() {
		return array(
			'type'       => 'object',
			'properties' => array(
				'url'      => array(
					'type'        => 'string',
					'description' => 'The URL to fetch',
				),
				'raw'      => array(
					'type'        => 'boolean',
					'description' => 'Return raw HTML without Markdown conversion (default: false)',
				),
				'max_size' => array(
					'type'        => 'integer',
					'description' => 'Maximum content size in bytes to return (default: 10KB)',
				),
				'offset'   => array(
					'type'        => 'integer',
					'description' => 'Byte offset to start reading from, for pagination (default: 0)',
				),
			),
			'required'   => array( 'url' ),
		);
	}

	/**
	 * Check if the current user can execute this tool.
	 *
	 * @return bool True if the current user has permission.
	 * @since     1.0.0
	 */
	public function current_user_can_execute() {
		return current_user_can( 'manage_options' );
	}

	/**
	 * Execute the tool with the given arguments.
	 *
	 * @param array $args The tool arguments.
	 *
	 * @return array The tool execution result.
	 * @since     1.0.0
	 */
	protected function execute( $args ) {
		$url      = esc_url_raw( $args['url'] );
		$raw      = $args['raw'] ?? false;
		$max_size = $args['max_size'] ?? self::DEFAULT_MAX_SIZE;
		$offset   = max( 0, $args['offset'] ?? 0 );

		if ( empty( $url ) ) {
			return $this->error_response( 'URL is required' );
		}

		$response = wp_remote_get(
			$url,
			array(
				'timeout'     => 30,
				'redirection' => 5,
				'user-agent'  => 'Agenistrator/' . AGENISTRATOR_VERSION,
			)
		);

		if ( is_wp_error( $response ) ) {
			return $this->error_response( 'Request failed: ' . $response->get_error_message() );
		}

		$status_code  = wp_remote_retrieve_response_code( $response );
		$content_type = wp_remote_retrieve_header( $response, 'content-type' );

		if ( $this->is_binary_content_type( $content_type ) ) {
			return $this->error_response( 'Binary content types are not supported. This tool only fetches text-based content.' );
		}

		$body = wp_remote_retrieve_body( $response );

		// Convert content to UTF-8 to ensure valid JSON encoding
		$body = $this->ensure_utf8( $body, $content_type );

		if ( ! $raw && stripos( $content_type, 'text/html' ) !== false ) {
			$body = $this->html_to_markdown( $body );
		}

		$total_size = strlen( $body );
		// Use mb_strcut to avoid breaking multi-byte UTF-8 sequences
		$body     = mb_strcut( $body, $offset, $max_size, 'UTF-8' );
		$has_more = ( $offset + strlen( $body ) ) < $total_size;

		return $this->success_response(
			'URL fetched successfully',
			array(
				'url'          => $url,
				'status_code'  => $status_code,
				'content_type' => $content_type,
				'total_size'   => $total_size,
				'offset'       => $offset,
				'size'         => strlen( $body ),
				'has_more'     => $has_more,
				'content'      => $body,
			)
		);
	}

	private function html_to_markdown( $html ) {
		$converter = new HtmlConverter(
			array(
				'strip_tags'   => true,
				'remove_nodes' => 'script style head nav footer',
				'hard_break'   => true,
				'header_style' => 'atx',
			)
		);
		return $converter->convert( $html );
	}

	/**
	 * Ensure content is valid UTF-8.
	 *
	 * Detects the source encoding from Content-Type header charset,
	 * converts to UTF-8, and sanitizes any remaining invalid sequences.
	 *
	 * @param string $content The raw content bytes.
	 * @param string $content_type The Content-Type header value.
	 * @return string Valid UTF-8 content.
	 */
	private function ensure_utf8( $content, $content_type ) {
		$source_encoding = $this->detect_encoding( $content, $content_type );

		// Convert to UTF-8 if source encoding is known and different
		if ( $source_encoding && strtoupper( $source_encoding ) !== 'UTF-8' ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- encoding conversion may fail silently for unknown encodings
			$converted = @mb_convert_encoding( $content, 'UTF-8', $source_encoding );
			if ( false !== $converted ) {
				$content = $converted;
			}
		}

		// Sanitize: remove any remaining invalid UTF-8 sequences
		// This handles cases where encoding detection failed or was incorrect
		$content = mb_convert_encoding( $content, 'UTF-8', 'UTF-8' );

		return $content;
	}

	/**
	 * Check if the content type indicates binary content.
	 *
	 * @param string $content_type The Content-Type header value.
	 * @return bool True if the content type is binary.
	 */
	private function is_binary_content_type( $content_type ) {
		// Extract the MIME type (before any parameters like charset)
		$mime_type = strtolower( trim( explode( ';', $content_type )[0] ) );

		// Text-based MIME types
		if ( strncmp( $mime_type, 'text/', 5 ) === 0 ) {
			return false;
		}

		// Common text-based application types
		$text_application_types = array(
			'application/json',
			'application/xml',
			'application/xhtml+xml',
			'application/javascript',
			'application/ecmascript',
			'application/x-javascript',
			'application/rss+xml',
			'application/atom+xml',
			'application/soap+xml',
			'application/xslt+xml',
			'application/mathml+xml',
			'application/ld+json',
			'application/manifest+json',
			'application/schema+json',
			'application/x-www-form-urlencoded',
		);

		if ( in_array( $mime_type, $text_application_types, true ) ) {
			return false;
		}

		// Treat +xml and +json suffixes as text
		if ( preg_match( '/\+(xml|json)$/', $mime_type ) ) {
			return false;
		}

		// Everything else (image/*, audio/*, video/*, application/octet-stream, etc.) is binary
		return true;
	}

	/**
	 * Detect the character encoding of content.
	 *
	 * Checks Content-Type header charset and falls back to mb_detect_encoding.
	 *
	 * @param string $content The raw content bytes.
	 * @param string $content_type The Content-Type header value.
	 * @return string|null The detected encoding or null if unknown.
	 */
	private function detect_encoding( $content, $content_type ) {
		// Check Content-Type header for charset
		if ( preg_match( '/charset\s*=\s*["\']?([^"\'\s;]+)/i', $content_type, $matches ) ) {
			return $this->normalize_encoding_name( $matches[1] );
		}

		// Fall back to mb_detect_encoding using PHP's default detection order
		$detected = mb_detect_encoding( $content, mb_detect_order(), true );

		return $detected ? $detected : null;
	}

	/**
	 * Normalize encoding name to PHP's expected format.
	 *
	 * @param string $encoding The encoding name from headers/meta tags.
	 * @return string The normalized encoding name.
	 */
	private function normalize_encoding_name( $encoding ) {
		$encoding = strtoupper( trim( $encoding ) );

		// Common aliases that may appear in HTTP headers
		$aliases = array(
			'UTF8'    => 'UTF-8',
			'LATIN1'  => 'ISO-8859-1',
			'LATIN-1' => 'ISO-8859-1',
			'ASCII'   => 'ASCII',
		);

		return $aliases[ $encoding ] ?? $encoding;
	}
}
