<?php
/* Copyright (C) 2026   Laurent Destailleur     <eldy@users.sourceforge.net>
 * Copyright (C) 2026	Nick Fragoulis
 * Copyright (C) 2026		MDW						<mdeweerd@users.noreply.github.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY, without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 * or see https://www.gnu.org/
 */

/**
 * \file    htdocs/ai/class/mcp.class.php
 * \ingroup ai
 * \brief   File of class to handle MCP (Model Context Protocol).
 */

require_once DOL_DOCUMENT_ROOT . "/ai/class/mcptool.class.php";

/**
 * Class to handle MCP (Model Context Protocol).
 *
 * This class is responsible for discovering, loading, and executing tools that implement
 * the Model Context Protocol. It supports both native tools from the ai/tools directory
 * and external tools registered via hooks.
 *
 * Context-aware: pass McpHandler::CTX_ASSISTANT (default) or McpHandler::CTX_MCP_SERVER
 * to the constructor so that the correct allow-list constant is applied.
 */
class McpHandler
{
	const CTX_ASSISTANT = 'assistant';

	const CTX_MCP_SERVER = 'mcp_server';

	/** @var DoliDB Database handler */
	private $db;

	/** @var User User object */
	private $user;

	/** @var Conf Configuration object */
	private $conf;

	/**
	 * @var McpTool[] Array of loaded tool instances, keyed by their base filename or class name.
	 */
	private $loadedTools = [];

	/**
	 * @var McpTool[] Associative array mapping tool *names* (from schema) to their instances.
	 * This provides O(1) lookup for execution.
	 */
	private $toolsByName = [];


	/**
	 * The active tool context. Determines which allow-list constant is read.
	 * @var string
	 */
	private $toolcontext;

	/**
	 * Constructor.
	 *
	 * @param DoliDB    $db      Database handler object
	 * @param User      $user    User object
	 * @param Conf|null $conf_obj	Configuration object. Falls back to global $conf when null.
	 * @param string    $toolcontext Pass McpHandler::CTX_ASSISTANT or McpHandler::CTX_MCP_SERVER.
	 *                               Defaults to CTX_ASSISTANT when empty.
	 */
	public function __construct($db, $user, $conf_obj = null, $toolcontext = '')
	{
		$this->db = $db;
		$this->user = $user;

		if ($conf_obj === null) {
			global $conf;
			$conf_obj = $conf;
		}
		$this->conf = $conf_obj;

		$this->toolcontext = (!empty($toolcontext)) ? $toolcontext : self::CTX_ASSISTANT;

		$this->loadTools();
	}

	/**
	 * Returns true if the given tool instance declares itself as a system tool.
	 *
	 * Detection is entirely delegated to the tool class via isSystem() — no tool
	 * names are hardcoded here. Any tool class that overrides isSystem() returning
	 * true is treated as a system tool automatically.
	 *
	 * @param McpTool $toolInstance The tool instance to evaluate.
	 * @return bool
	 */
	private function isSystemTool($toolInstance)
	{
		return (method_exists($toolInstance, 'isSystem') && $toolInstance->isSystem());
	}

	/**
	 * Returns the configured allow-list for the current context as an array of
	 * tool names.
	 *
	 * Logic:
	 *   constant not set / empty string → no restriction → returns array()
	 *   constant = 'NONE'               → all blocked    → returns array('__blocked__')
	 *   otherwise                       → returns the list of allowed names
	 *
	 * The sentinel '__blocked__' will never match any real tool name so
	 * in_array() checks against it always return false.
	 *
	 * @return string[]
	 */
	private function getAllowedToolsList()
	{
		if ($this->toolcontext === self::CTX_MCP_SERVER) {
			$constName = 'AI_MCP_SERVER_ALLOWED_TOOLS';
		} else {
			$constName = 'AI_ASSISTANT_ALLOWED_TOOLS';
		}

		$raw = getDolGlobalString($constName);

		if ($raw === '') {
			// Constant not yet configured — allow everything
			return array();
		}

		if ($raw === 'NONE') {
			// Admin explicitly disabled all tools via the preset button
			return array('__blocked__');
		}

		return array_values(array_filter(array_map('trim', explode(',', $raw))));
	}

	/**
	 * Resolves a raw allow-list constant value into an explicit PHP array of tool names.
	 *
	 * @param string   $raw                Raw value of the constant from getDolGlobalString().
	 * @param string[] $allDiscoveredTools Full list of all non-system tool names discovered.
	 * @return string[] Explicit list of currently allowed tool names.
	 */
	public static function resolveAllowList($raw, $allDiscoveredTools)
	{
		if ($raw === '') {
			// Not yet configured → implicitly all tools are allowed
			return $allDiscoveredTools;
		}
		if ($raw === 'NONE') {
			// Admin explicitly disabled everything
			return array();
		}
		// Explicit list stored by a previous save
		return array_values(array_filter(array_map('trim', explode(',', $raw))));
	}


	/**
	 * Load all available MCP tools.
	 *
	 * This method scans the ai/tools directory for native tools and executes the
	 * 'addMcpTools' hook to allow external modules to register their own tools.
	 *
	 * @return void
	 */
	private function loadTools()
	{
		$this->loadNativeTools();
		$this->loadExternalTools();
	}

	/**
	 * Load native tools from the specific tools directory.
	 *
	 * Scans the ai/tools/ directory for PHP files. It validates the file paths
	 * for security, attempts to load the corresponding class (following the
	 * convention "Tool" + PascalCase filename), and registers the tool if it
	 * is a valid instance of McpTool.
	 *
	 * @return void
	 */
	private function loadNativeTools()
	{
		$toolsDir = DOL_DOCUMENT_ROOT . '/ai/tools/';
		if (!is_dir($toolsDir)) {
			dol_syslog('[McpHandler] MCP tools directory not found: ' . $toolsDir, LOG_INFO);
			return;
		}

		$files = glob($toolsDir . '*.php');
		foreach ($files as $file) {
			try {
				// Validate the file path before inclusion
				$realFilePath = realpath($file);
				if ($realFilePath === false || strpos($realFilePath, realpath($toolsDir)) !== 0) {
					dol_syslog('[McpHandler] Attempted to load tool outside of allowed directory: ' . $file, LOG_WARNING);
					continue;
				}

				require_once $realFilePath;

				$basename = basename($file, '.class.php');
				$className = 'Tool' . str_replace(' ', '', ucwords(str_replace('_', ' ', $basename)));

				if (!class_exists($className)) {
					dol_syslog("[McpHandler] Tool class '{$className}' not found in file '{$file}'.", LOG_WARNING);
					continue;
				}

				$toolInstance = new $className($this->db, $this->user, $this->conf);

				if ($toolInstance instanceof McpTool) {
					$this->registerTool($basename, $toolInstance);
				} else {
					dol_syslog("[McpHandler] Tool class '{$className}' does not extend McpTool.", LOG_ERR);
				}
			} catch (\Throwable $e) {
				dol_syslog("[McpHandler] Failed to load tool from file '{$file}': " . $e->getMessage(), LOG_ERR);
			}
		}
	}
	/**
	 * Loads external tools registered via the 'addMcpTools' hook.
	 *
	 * Initializes the HookManager for the 'aimcp' context and executes the
	 * 'addMcpTools' hook. It expects modules to populate the result array
	 * with arrays containing valid McpTool instances.
	 *
	 * @return void
	 */
	private function loadExternalTools()
	{
		global $hookmanager;
		if (!is_object($hookmanager)) {
			require_once DOL_DOCUMENT_ROOT . '/core/class/hookmanager.class.php';
			$hookmanager = new HookManager($this->db);
		}

		$hookmanager->initHooks(['aimcp']);

		$parameters = ['db' => $this->db, 'user' => $this->user, 'conf' => $this->conf];
		$action = '';

		try {
			$hookmanager->executeHooks('addMcpTools', $parameters, $this, $action);

			if (!is_array($hookmanager->resArray)) {
				return;
			}

			foreach ($hookmanager->resArray as $moduleTools) {
				if (!is_array($moduleTools)) {
					continue;
				}
				foreach ($moduleTools as $toolInstance) {
					if ($toolInstance instanceof McpTool) {
						$this->registerTool(get_class($toolInstance), $toolInstance);
					} else {
						dol_syslog('[McpHandler] A module provided a tool that is not an instance of McpTool.', LOG_WARNING);
					}
				}
			}
		} catch (\Throwable $e) {
			dol_syslog('[McpHandler] Error during \'addMcpTools\' hook execution: ' . $e->getMessage(), LOG_ERR);
		}
	}

	/**
	 * Helper method to register a tool instance and populate lookup arrays.
	 *
	 * @param string   $key          A unique key for the tool (e.g., filename or class name).
	 * @param McpTool  $toolInstance The instantiated tool object.
	 *
	 * @return void
	 */
	private function registerTool(string $key, McpTool $toolInstance)
	{
		$this->loadedTools[$key] = $toolInstance;

		// Populate the lookup map
		foreach ($toolInstance->getDefinitions() as $def) {
			if (isset($def['name'])) {
				if (isset($this->toolsByName[$def['name']])) {
					dol_syslog(
						"[McpHandler] Tool name conflict: '{$def['name']}' is already registered by '" . get_class($this->toolsByName[$def['name']]) . "'. Skipping registration from '" . get_class($toolInstance) . "'.",
						LOG_WARNING
					);
				} else {
					$this->toolsByName[$def['name']] = $toolInstance;
				}
			}
		}
		dol_syslog('[McpHandler] Successfully registered MCP tool: ' . get_class($toolInstance), LOG_INFO);
	}

	/**
	 * Returns the full schema of every loaded tool with no allow-list filtering.
	 * Adds is_system and class_name metadata needed by admin/configure_tools.php.
	 *
	 * Must not be called from any user-facing entry point — admin use only.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function getToolsSchemaUnfiltered()
	{
		$schema = array();

		foreach ($this->loadedTools as $tool) {
			$isSystem  = $this->isSystemTool($tool);
			$className = get_class($tool);

			foreach ($tool->getDefinitions() as $def) {
				$def['is_system']  = $isSystem;
				$def['class_name'] = $className;
				$def['categories'] = $tool->getCategories();
				$schema[] = $def;
			}
		}

		return $schema;
	}

	/**
	 * Returns the schema of all tools permitted in the current context.
	 *
	 * System tools (isSystem() = true) are always included and tagged with
	 * is_system = true so callers can identify and exclude them from the schema
	 * sent to the LLM (system tools are parse_intent.php infrastructure —
	 * they must never be called directly by the model).
	 *
	 * All other tools are filtered against the tool context allow-list.
	 *
	 * @return array<int, array<string, mixed>> Array of tool schemas
	 */
	public function getToolsSchema(): array
	{
		$allowed = $this->getAllowedToolsList();
		$schema  = [];

		foreach ($this->loadedTools as $tool) {
			$isSystem = $this->isSystemTool($tool);

			foreach ($tool->getDefinitions() as $def) {
				$name = isset($def['name']) ? $def['name'] : '';

				if ($isSystem) {
					// Always include system tools but tag them so parse_intent.php
					// can strip them from $toolsForLLM while keeping them available
					// for the validation check (executeTool must still be able to
					// run respond_to_user, ask_for_clarification, etc.).
					$def['is_system']  = true;
					$def['categories'] = $tool->getCategories();
					$schema[] = $def;
					continue;
				}

				$def['is_system'] = false;

				if (empty($allowed)) {
					// No restriction configured — include everything
					$def['categories'] = $tool->getCategories();
					$schema[] = $def;
					continue;
				}

				if (in_array($name, $allowed, true)) {
					$def['categories'] = $tool->getCategories();
					$schema[] = $def;
				}
				// Not in $allowed — silently omitted; LLM never sees this tool
			}
		}

		return $schema;
	}

	/**
	 * Returns the schema of tools permitted in the current context, with system
	 * tools completely excluded. This is the exact list sent to the LLM.
	 *
	 * System tools (ask_for_confirmation, respond_to_user, etc.) must NEVER be
	 * visible to the model. If the LLM sees ask_for_confirmation in its schema
	 * it will call it directly with wrong arguments instead of the real action
	 * tool, causing an infinite confirmation loop on the client side.
	 *
	 * parse_intent.php uses this method to build $toolsForLLM, and keeps
	 * getToolsSchema() separately only for the post-LLM validation step
	 * (where system tools must still be accepted as valid responses).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function getToolsSchemaForLLM()
	{
		$allowed = $this->getAllowedToolsList();
		$schema  = array();

		foreach ($this->loadedTools as $tool) {
			// Check isSystem() class method first (requires conversation.class.php
			// to implement it). This is the preferred path for future extensibility.
			if ($this->isSystemTool($tool)) {
				continue;
			}

			foreach ($tool->getDefinitions() as $def) {
				$name = isset($def['name']) ? $def['name'] : '';

				// Check is_system flag in the definition array itself.
				// This is set directly in conversation.class.php getDefinitions()
				// and works even if the isSystem() class method is not yet deployed.
				if (!empty($def['is_system'])) {
					continue;
				}

				if (empty($allowed)) {
					// No restriction configured — include everything
					$def['categories'] = $tool->getCategories();
					$schema[] = $def;
					continue;
				}

				if (in_array($name, $allowed, true)) {
					$def['categories'] = $tool->getCategories();
					$schema[] = $def;
				}
			}
		}

		return $schema;
	}

	/**
	 * Execute a specific tool by its name.
	 *
	 * Enforces the tool context allow-list as a second gate so that even a crafted
	 * direct request cannot run a tool that was disabled in the admin UI.
	 *
	 * @param string               $toolName The name of the tool to execute.
	 * @param array<string, mixed> $args     The arguments to pass to the tool.
	 *
	 * @return array<string, mixed> The result of the tool execution or an error array.
	 */
	public function executeTool(string $toolName, array $args): array
	{
		if (!isset($this->toolsByName[$toolName])) {
			return ["error" => "Tool '{$toolName}' not found."];
		}

		$toolInstance = $this->toolsByName[$toolName];

		// enforce tool context allow-list (system tools always pass through)
		if (!$this->isSystemTool($toolInstance)) {
			$allowed = $this->getAllowedToolsList();

			if (!empty($allowed) && !in_array($toolName, $allowed, true)) {
				dol_syslog(
					"[McpHandler] Blocked execution of tool '$toolName' in tool context '{$this->toolcontext}' (not in allow-list).",
					LOG_WARNING
				);
				return array('error' => "Tool '" . $toolName . "' is not available in this tool context.");
			}
		}

		// execute
		try {
			dol_syslog('[McpHandler] Executing tool \'' . $toolName . '\' with args: ' . json_encode($args), LOG_INFO);
			$result = $toolInstance->execute($toolName, $args);
			dol_syslog('[McpHandler] Tool \'' . $toolName . '\' executed successfully.', LOG_INFO);
			return $result;
		} catch (\Throwable $e) {
			dol_syslog('[McpHandler] Error executing tool \'' . $toolName . '\': ' . $e->getMessage(), LOG_ERR);
			return ["error" => "An internal error occurred while executing the tool '{$toolName}'. Details have been logged."];
		}
	}
}
