<?php

namespace Antimanual\Tools;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Central registry of AI-callable tools ("actions").
 *
 * A single source of truth shared by two consumers:
 *   - The MCP server (\Antimanual\Mcp\McpServer), which exposes tools to
 *     external AI agents such as Claude and ChatGPT.
 *   - Chatbot function calling (\Antimanual\Api\ChatbotController), which lets
 *     the on-site chatbot perform actions instead of only answering.
 *
 * Each tool declares a JSON Schema for its arguments, a required capability
 * (empty string = safe for anonymous/public callers), a read-only flag, and a
 * PHP handler. Execution always validates arguments and enforces the capability
 * before invoking the handler — never trust the model's arguments.
 *
 * Tools are filterable via `atml_ai_tools` so Pro add-ons can register more.
 *
 * @package Antimanual
 */
class ToolRegistry {

	/**
	 * Memoized tool definitions, keyed by tool name.
	 *
	 * @var array<string, array>|null
	 */
	private static $tools = null;

	/**
	 * Get all registered tools keyed by name.
	 *
	 * @return array<string, array>
	 */
	public static function all(): array {
		if ( null === self::$tools ) {
			$defs  = BuiltinTools::definitions();
			$defs  = apply_filters( 'atml_ai_tools', $defs );
			$tools = [];

			foreach ( (array) $defs as $tool ) {
				if ( empty( $tool['name'] ) || empty( $tool['callback'] ) || ! is_callable( $tool['callback'] ) ) {
					continue;
				}

				$tools[ $tool['name'] ] = wp_parse_args(
					$tool,
					[
						'title'        => $tool['name'],
						'description'  => '',
						'input_schema' => [ 'type' => 'object', 'properties' => [], 'required' => [] ],
						'capability'   => '',
						'read_only'    => true,
					]
				);
			}

			self::$tools = $tools;
		}

		return self::$tools;
	}

	/**
	 * Reset the memoized registry (used in tests / after dynamic registration).
	 */
	public static function flush(): void {
		self::$tools = null;
	}

	/**
	 * Get a single tool definition by name.
	 *
	 * @param string $name Tool name.
	 * @return array|null
	 */
	public static function get( string $name ): ?array {
		$tools = self::all();
		return $tools[ $name ] ?? null;
	}

	/**
	 * Get tools available for a given context.
	 *
	 * @param array $context {
	 *     @type string $source     'mcp' | 'chatbot'.
	 *     @type bool   $public     True when the caller is an anonymous visitor.
	 *     @type bool   $write      True when write (non read-only) tools are allowed.
	 *     @type array  $allowed    Optional allow-list of tool names; empty = all.
	 * }
	 * @return array<string, array>
	 */
	public static function available( array $context = [] ): array {
		$public  = ! empty( $context['public'] );
		$write   = ! empty( $context['write'] );
		$allowed = isset( $context['allowed'] ) && is_array( $context['allowed'] ) ? $context['allowed'] : [];

		$out = [];
		foreach ( self::all() as $name => $tool ) {
			if ( ! empty( $allowed ) && ! in_array( $name, $allowed, true ) ) {
				continue;
			}

			// Write tools require the write flag.
			if ( empty( $tool['read_only'] ) && ! $write ) {
				continue;
			}

			// Anonymous callers only ever get capability-free, read-only tools.
			if ( $public && ( '' !== $tool['capability'] || empty( $tool['read_only'] ) ) ) {
				continue;
			}

			$out[ $name ] = $tool;
		}

		return $out;
	}

	/**
	 * Convert a tool definition to the JSON-Schema shape used by MCP
	 * (`inputSchema`) and provider function-calling (`parameters`).
	 *
	 * @param array $tool Tool definition.
	 * @return array
	 */
	public static function to_schema( array $tool ): array {
		$schema = $tool['input_schema'] ?? [ 'type' => 'object', 'properties' => [] ];
		if ( empty( $schema['type'] ) ) {
			$schema['type'] = 'object';
		}
		if ( ! isset( $schema['properties'] ) ) {
			$schema['properties'] = [];
		}
		return $schema;
	}

	/**
	 * Execute a tool by name with the given raw arguments.
	 *
	 * Enforces capability, sanitizes arguments against the schema, then invokes
	 * the handler. Returns a structured array on success or WP_Error on failure.
	 *
	 * @param string $name    Tool name.
	 * @param array  $args    Raw arguments from the model.
	 * @param array  $context Execution context (source, public, write, user_id).
	 * @return array|\WP_Error
	 */
	public static function execute( string $name, array $args, array $context = [] ) {
		$tool = self::get( $name );

		if ( ! $tool ) {
			return new \WP_Error( 'tool_not_found', __( 'Unknown tool.', 'antimanual' ), [ 'status' => 404 ] );
		}

		$public = ! empty( $context['public'] );

		// Anonymous callers may only use capability-free, read-only tools.
		if ( $public && ( '' !== $tool['capability'] || empty( $tool['read_only'] ) ) ) {
			return new \WP_Error( 'tool_forbidden', __( 'This action is not available.', 'antimanual' ), [ 'status' => 403 ] );
		}

		// Write tools require the write flag from the caller's context.
		if ( empty( $tool['read_only'] ) && empty( $context['write'] ) ) {
			return new \WP_Error( 'tool_read_only', __( 'Write actions are disabled for this connection.', 'antimanual' ), [ 'status' => 403 ] );
		}

		// Capability check for non-public tools.
		if ( ! $public && '' !== $tool['capability'] && ! current_user_can( $tool['capability'] ) ) {
			return new \WP_Error( 'tool_forbidden', __( 'You are not allowed to run this action.', 'antimanual' ), [ 'status' => 403 ] );
		}

		$clean = self::sanitize_args( $args, $tool['input_schema'] ?? [] );

		// Enforce required arguments.
		$required = $tool['input_schema']['required'] ?? [];
		foreach ( (array) $required as $field ) {
			if ( ! isset( $clean[ $field ] ) || '' === $clean[ $field ] ) {
				return new \WP_Error(
					'tool_missing_arg',
					sprintf(
						/* translators: %s: argument name */
						__( 'Missing required argument: %s', 'antimanual' ),
						$field
					),
					[ 'status' => 400 ]
				);
			}
		}

		try {
			$result = call_user_func( $tool['callback'], $clean, $context );
		} catch ( \Throwable $e ) {
			return new \WP_Error( 'tool_error', __( 'The action failed to run.', 'antimanual' ), [ 'status' => 500 ] );
		}

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return is_array( $result ) ? $result : [ 'result' => $result ];
	}

	/**
	 * Sanitize raw model arguments against a tool's JSON Schema.
	 *
	 * Only known properties are kept, and each is cast/sanitized by its declared
	 * type. Unknown keys are dropped — the model cannot smuggle extra fields.
	 *
	 * @param array $args   Raw arguments.
	 * @param array $schema JSON Schema (object with `properties`).
	 * @return array
	 */
	private static function sanitize_args( array $args, array $schema ): array {
		$properties = $schema['properties'] ?? [];
		if ( empty( $properties ) || ! is_array( $properties ) ) {
			return [];
		}

		$clean = [];
		foreach ( $properties as $key => $spec ) {
			if ( ! array_key_exists( $key, $args ) ) {
				continue;
			}

			$value = $args[ $key ];
			$type  = $spec['type'] ?? 'string';

			switch ( $type ) {
				case 'integer':
					$clean[ $key ] = (int) $value;
					break;
				case 'number':
					$clean[ $key ] = (float) $value;
					break;
				case 'boolean':
					$clean[ $key ] = rest_sanitize_boolean( $value );
					break;
				case 'array':
					$clean[ $key ] = is_array( $value )
						? array_map( 'sanitize_text_field', array_map( 'strval', $value ) )
						: [];
					break;
				case 'string':
				default:
					$value = is_scalar( $value ) ? (string) $value : '';
					// Allow richer HTML for explicit content fields.
					$clean[ $key ] = ! empty( $spec['atml_html'] )
						? wp_kses_post( $value )
						: sanitize_textarea_field( $value );
					break;
			}

			// Clamp integers/numbers to declared bounds when present.
			if ( in_array( $type, [ 'integer', 'number' ], true ) ) {
				if ( isset( $spec['minimum'] ) ) {
					$clean[ $key ] = max( $spec['minimum'], $clean[ $key ] );
				}
				if ( isset( $spec['maximum'] ) ) {
					$clean[ $key ] = min( $spec['maximum'], $clean[ $key ] );
				}
			}
		}

		return $clean;
	}
}
