<?php

declare(strict_types=1);

namespace RedMCP;

use RuntimeException;
use Throwable;

final class McpDispatcher
{
    private const PROJECT_ID_DESCRIPTION = 'Redmine project identifier or numeric id. If unsure, call redmine_find_project first and use project_id_to_use.';

    private const ISSUE_FIELD_ARGUMENT_KEYS = [
        'project_id',
        'subject',
        'description',
        'tracker_id',
        'status_id',
        'priority_id',
        'assigned_to_id',
        'category_id',
        'parent_issue_id',
        'parent_id',
        'uploads',
        'due_date',
        'start_date',
        'notes',
        'private_notes',
        'custom_fields',
        'watcher_user_ids',
        'is_private',
        'estimated_hours',
        'done_ratio',
        'fixed_version_id',
    ];

    private RedmineClient $redmine;
    private McpDebugLogger $logger;

    public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null)
    {
        $this->redmine = $redmine;
        $this->logger = $logger ?? new McpDebugLogger(null);
    }

    /**
     * @param array<string,mixed> $message
     *
     * @return array<string,mixed>|null
     */
    public function handleMessage(array $message, array $context = []): ?array
    {
        $id = $message['id'] ?? null;
        if ($id === null) {
            return null;
        }

        $started = microtime(true);
        $method = (string) ($message['method'] ?? '');
        $params = is_array($message['params'] ?? null) ? $message['params'] : [];

        try {
            $result = $this->dispatch($method, $params);
            $this->logCall($context, $method, $params, true, $started);
            return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
        } catch (Throwable $exception) {
            $this->logCall($context, $method, $params, false, $started, $exception->getMessage());
            return [
                'jsonrpc' => '2.0',
                'id' => $id,
                'error' => [
                    'code' => -32000,
                    'message' => $exception->getMessage(),
                ],
            ];
        }
    }

    /**
     * @param array<string,mixed> $params
     *
     * @return array<string,mixed>
     */
    private function dispatch(string $method, array $params): array
    {
        switch ($method) {
            case 'initialize':
                return [
                    'protocolVersion' => '2025-03-26',
                    'capabilities' => [
                        'tools' => ['listChanged' => false],
                    ],
                    'serverInfo' => [
                        'name' => 'redMCP',
                        'version' => '0.1.0',
                    ],
                ];
            case 'ping':
                return [];
            case 'tools/list':
                return ['tools' => $this->tools()];
            case 'tools/call':
                return $this->callTool($params);
            case 'resources/list':
                return ['resources' => []];
            case 'prompts/list':
                return ['prompts' => []];
            default:
                throw new RuntimeException('Unsupported MCP method: ' . $method);
        }
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function tools(): array
    {
        return [
            $this->tool('redmine_list_projects', 'List available Redmine projects using native /projects.json. Use redmine_find_project to resolve a human project name.', [
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
                'params' => ['type' => 'object', 'description' => 'Raw Redmine project list params; overrides friendly fields on conflict.'],
            ]),
            $this->tool('redmine_find_project', 'Find the Redmine project identifier to use from a human project name, identifier, or numeric id. Read-only; use before create/list tools when project_id is uncertain.', [
                'query' => ['type' => 'string', 'description' => 'Human project name, project identifier, or numeric project id to resolve.'],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 25, 'description' => 'Maximum ranked matches to return. Defaults to 10.'],
            ], ['query']),
            $this->tool('redmine_get_project', 'Fetch one Redmine project by id or identifier.', [
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'params' => ['type' => 'object', 'description' => 'Optional Redmine project params such as include=trackers,issue_categories,enabled_modules.'],
            ], ['project_id']),
            $this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
                'params' => ['type' => 'object', 'description' => 'Raw Redmine membership list params; overrides friendly fields on conflict.'],
            ], ['project_id']),
            $this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [
                'status' => ['description' => 'User status such as active, registered, locked, all, or a Redmine status id.'],
                'name' => ['type' => 'string', 'description' => 'Filter users by name.'],
                'group_id' => ['type' => ['string', 'integer'], 'description' => 'Filter users by group id.'],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
                'params' => ['type' => 'object', 'description' => 'Raw Redmine user list params; overrides friendly fields on conflict.'],
            ]),
            $this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
                'user_id' => ['type' => 'integer'],
                'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
            ], ['user_id']),
            $this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'status' => ['description' => 'Issue status such as open, closed, all, or a Redmine status id.'],
                'status_id' => ['description' => 'Raw Redmine status id or status token.'],
                'tracker_id' => ['type' => ['string', 'integer']],
                'assigned_to_id' => ['type' => ['string', 'integer']],
                'author_id' => ['type' => ['string', 'integer']],
                'priority_id' => ['type' => ['string', 'integer']],
                'category_id' => ['type' => ['string', 'integer']],
                'query_id' => ['type' => ['string', 'integer']],
                'created' => ['description' => 'Friendly created_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
                'updated' => ['description' => 'Friendly updated_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
                'due' => ['description' => 'Friendly due_date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
                'filters' => ['type' => 'object', 'description' => 'Raw Redmine issue list filters; overrides friendly fields on conflict.'],
            ]),
            $this->tool('redmine_search', 'Search Redmine using native /search.json.', [
                'query' => ['type' => 'string'],
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'scope' => ['type' => 'string'],
                'all_words' => ['type' => ['boolean', 'string', 'integer']],
                'titles_only' => ['type' => ['boolean', 'string', 'integer']],
                'open_issues' => ['type' => ['boolean', 'string', 'integer']],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
                'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
            ], ['query']),
            $this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
                'query' => ['type' => 'string'],
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'scope' => ['type' => 'string'],
                'all_words' => ['type' => ['boolean', 'string', 'integer']],
                'titles_only' => ['type' => ['boolean', 'string', 'integer']],
                'open_issues' => ['type' => ['boolean', 'string', 'integer']],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
                'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
            ], ['query']),
            $this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [
                'issue_id' => ['type' => 'integer'],
                'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'],
            ], ['issue_id']),
            $this->tool('redmine_list_issue_relations', 'List issue relations attached to one Redmine issue.', [
                'issue_id' => ['type' => 'integer'],
            ], ['issue_id']),
            $this->tool('redmine_get_issue_relation', 'Fetch one Redmine issue relation link by relation id.', [
                'relation_id' => ['type' => 'integer'],
            ], ['relation_id']),
            $this->tool('redmine_create_issue_relation', 'Create a Redmine issue relation link. Defaults relation_type to relates.', [
                'issue_id' => ['type' => 'integer', 'description' => 'Source issue id.'],
                'fields' => ['type' => 'object', 'description' => 'Relation fields including issue_to_id, optional relation_type, and optional delay.'],
            ], ['issue_id', 'fields']),
            $this->tool('redmine_remove_issue_relation', 'Unlink one mistaken or explicitly unwanted issue relation. This removes only the relationship, not either issue.', [
                'relation_id' => ['type' => 'integer'],
            ], ['relation_id']),
            $this->tool('redmine_list_issue_children', 'List child issues whose parent_id is the given issue id.', [
                'issue_id' => ['type' => 'integer'],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
                'page' => ['type' => 'integer', 'minimum' => 1],
                'offset' => ['type' => 'integer', 'minimum' => 0],
                'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
                'filters' => ['type' => 'object', 'description' => 'Additional raw Redmine issue list filters. parent_id is controlled by issue_id.'],
            ], ['issue_id']),
            $this->tool('redmine_set_issue_parent', 'Set an issue parent/subtask link.', [
                'issue_id' => ['type' => 'integer'],
                'parent_issue_id' => ['type' => 'integer'],
            ], ['issue_id', 'parent_issue_id']),
            $this->tool('redmine_clear_issue_parent', 'Clear an issue parent/subtask link without deleting either issue.', [
                'issue_id' => ['type' => 'integer'],
            ], ['issue_id']),
            $this->tool('redmine_issue_with_helpdesk', 'Fetch one issue plus Helpdesk ticket/message context when available.', [
                'issue_id' => ['type' => 'integer'],
                'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200],
                'include' => ['type' => 'array', 'items' => ['type' => 'string']],
            ], ['issue_id']),
            $this->tool('redmine_create_issue', 'Create a Redmine issue.', [
                'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'],
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION . ' Flat value is copied into fields.'],
                'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
                'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
                'tracker_id' => ['type' => ['string', 'integer']],
                'status_id' => ['type' => ['string', 'integer']],
                'priority_id' => ['type' => ['string', 'integer']],
                'assigned_to_id' => ['type' => ['string', 'integer']],
                'category_id' => ['type' => ['string', 'integer']],
                'parent_issue_id' => ['type' => ['string', 'integer']],
                'parent_id' => ['type' => ['string', 'integer']],
                'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
                'due_date' => ['type' => 'string'],
                'start_date' => ['type' => 'string'],
                'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
                'watcher_user_ids' => ['type' => 'array'],
                'is_private' => ['type' => ['boolean', 'string', 'integer']],
                'estimated_hours' => ['type' => ['number', 'string', 'integer']],
                'done_ratio' => ['type' => ['integer', 'string']],
                'fixed_version_id' => ['type' => ['string', 'integer']],
            ]),
            $this->tool('redmine_update_issue', 'Update a Redmine issue. Helpdesk email is opt-in.', [
                'issue_id' => ['type' => 'integer'],
                'fields' => ['type' => 'object'],
                'options' => ['type' => 'object', 'description' => 'Pass send_helpdesk_email=true only for customer-visible Helpdesk replies.'],
                'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
                'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
                'notes' => ['type' => 'string', 'description' => 'Flat issue note; copied into fields.'],
                'tracker_id' => ['type' => ['string', 'integer']],
                'status_id' => ['type' => ['string', 'integer']],
                'priority_id' => ['type' => ['string', 'integer']],
                'assigned_to_id' => ['type' => ['string', 'integer']],
                'category_id' => ['type' => ['string', 'integer']],
                'parent_issue_id' => ['type' => ['string', 'integer']],
                'parent_id' => ['type' => ['string', 'integer']],
                'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
                'due_date' => ['type' => 'string'],
                'start_date' => ['type' => 'string'],
                'private_notes' => ['type' => ['boolean', 'string', 'integer']],
                'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
                'watcher_user_ids' => ['type' => 'array'],
                'is_private' => ['type' => ['boolean', 'string', 'integer']],
                'estimated_hours' => ['type' => ['number', 'string', 'integer']],
                'done_ratio' => ['type' => ['integer', 'string']],
                'fixed_version_id' => ['type' => ['string', 'integer']],
            ], ['issue_id']),
            $this->tool('redmine_list_project_issue_categories', 'List issue categories for a Redmine project.', [
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
            ], ['project_id']),
            $this->tool('redmine_get_issue_category', 'Fetch one Redmine issue category by id.', [
                'category_id' => ['type' => 'integer'],
            ], ['category_id']),
            $this->tool('redmine_create_issue_category', 'Create an issue category for a project. Category deletion is intentionally not exposed.', [
                'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
                'fields' => ['type' => 'object', 'description' => 'Category fields including name and optional assigned_to_id.'],
            ], ['project_id', 'fields']),
            $this->tool('redmine_update_issue_category', 'Update an issue category. Category deletion is intentionally not exposed.', [
                'category_id' => ['type' => 'integer'],
                'fields' => ['type' => 'object'],
            ], ['category_id', 'fields']),
            $this->tool('redmine_get_attachment', 'Fetch Redmine attachment metadata by id.', [
                'attachment_id' => ['type' => 'integer'],
            ], ['attachment_id']),
            $this->tool('redmine_upload_attachment', 'Upload a local path, base64 content, data URL, or file envelope to Redmine and return an upload token for issue create/update uploads. Use this for PDFs and other non-image files instead of image_url.', [
                'path' => ['type' => 'string', 'description' => 'Readable local file path to upload.'],
                'base64_content' => ['type' => 'string', 'description' => 'Base64-encoded attachment bytes.'],
                'base64' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
                'data' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
                'blob' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
                'data_url' => ['type' => 'string', 'description' => 'Base64 data URL such as data:application/pdf;base64,....'],
                'filename' => ['type' => 'string', 'description' => 'Required for plain base64_content; optional for path or data_url.'],
                'name' => ['type' => 'string', 'description' => 'Alias for filename.'],
                'content_type' => ['type' => 'string', 'description' => 'Attachment MIME type.'],
                'mime_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
                'mimeType' => ['type' => 'string', 'description' => 'Alias for content_type.'],
                'media_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
                'description' => ['type' => 'string'],
                'file' => [
                    'type' => 'object',
                    'description' => 'File envelope with name/filename, mime_type/content_type, and data/base64_content/blob, or a path/data_url.',
                    'additionalProperties' => true,
                ],
            ]),
            $this->tool('redmine_download_attachment', 'Download an attachment to an explicit safe local path under /tmp or this repository. Base64 response content is optional and size-limited.', [
                'attachment_id' => ['type' => 'integer'],
                'path' => ['type' => 'string', 'description' => 'Destination path under /tmp or the repository tree.'],
                'include_base64' => ['type' => 'boolean'],
                'max_base64_bytes' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 1048576],
            ], ['attachment_id', 'path']),
            $this->tool('redmine_update_attachment', 'Update Redmine attachment metadata such as filename or description.', [
                'attachment_id' => ['type' => 'integer'],
                'fields' => ['type' => 'object'],
            ], ['attachment_id', 'fields']),
            $this->tool('redmine_send_helpdesk_response', 'Send a customer-visible Helpdesk email response.', [
                'issue_id' => ['type' => 'integer'],
                'content' => ['type' => 'string'],
                'options' => ['type' => 'object', 'description' => 'Optional to_address, cc_address, bcc_address, and status_id.'],
            ], ['issue_id', 'content']),
        ];
    }

    /**
     * @param array<string,mixed> $properties
     * @param array<int,string> $required
     *
     * @return array<string,mixed>
     */
    private function tool(string $name, string $description, array $properties, array $required = []): array
    {
        return [
            'name' => $name,
            'description' => $description,
            'inputSchema' => [
                'type' => 'object',
                'properties' => $properties,
                'required' => $required,
                'additionalProperties' => false,
            ],
        ];
    }

    /**
     * @param array<string,mixed> $params
     *
     * @return array<string,mixed>
     */
    private function callTool(array $params): array
    {
        $name = (string) ($params['name'] ?? '');
        $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];

        switch ($name) {
            case 'redmine_list_projects':
                $result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
                break;
            case 'redmine_find_project':
                $result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
                break;
            case 'redmine_get_project':
                $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
                break;
            case 'redmine_list_project_memberships':
                $result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), ListQueryNormalizer::listParams($arguments));
                break;
            case 'redmine_list_users':
                $result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
                break;
            case 'redmine_get_user':
                $result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
                break;
            case 'redmine_list_issues':
                $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments));
                break;
            case 'redmine_search':
                $result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
                break;
            case 'redmine_search_issues':
                $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
                break;
            case 'redmine_get_issue':
                $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
                break;
            case 'redmine_list_issue_relations':
                $result = $this->redmine->listIssueRelations($this->intArg($arguments, 'issue_id'));
                break;
            case 'redmine_get_issue_relation':
                $result = $this->redmine->issueRelation($this->intArg($arguments, 'relation_id'));
                break;
            case 'redmine_create_issue_relation':
                $result = $this->redmine->createIssueRelation($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'));
                break;
            case 'redmine_remove_issue_relation':
                $result = ['ok' => $this->redmine->removeIssueRelation($this->intArg($arguments, 'relation_id'))];
                break;
            case 'redmine_list_issue_children':
                $filters = ListQueryNormalizer::issueFilters($arguments);
                unset($filters['parent_id']);
                $result = $this->redmine->listIssueChildren($this->intArg($arguments, 'issue_id'), $filters);
                break;
            case 'redmine_set_issue_parent':
                $result = ['ok' => $this->redmine->setIssueParent($this->intArg($arguments, 'issue_id'), $this->intArg($arguments, 'parent_issue_id'))];
                break;
            case 'redmine_clear_issue_parent':
                $result = ['ok' => $this->redmine->clearIssueParent($this->intArg($arguments, 'issue_id'))];
                break;
            case 'redmine_issue_with_helpdesk':
                $result = $this->redmine->issueWithHelpdesk(
                    $this->intArg($arguments, 'issue_id'),
                    $this->intArg($arguments, 'message_limit', 100),
                    $this->stringListArg($arguments, 'include', ['journals', 'attachments'])
                );
                break;
            case 'redmine_create_issue':
                $result = $this->redmine->createIssue($this->issueFieldsArg($arguments));
                break;
            case 'redmine_update_issue':
                $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))];
                break;
            case 'redmine_list_project_issue_categories':
                $result = $this->redmine->listProjectIssueCategories($this->projectIdArg($arguments, 'project_id'));
                break;
            case 'redmine_get_issue_category':
                $result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id'));
                break;
            case 'redmine_create_issue_category':
                $result = $this->redmine->createIssueCategory($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'fields'));
                break;
            case 'redmine_update_issue_category':
                $result = $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields'));
                break;
            case 'redmine_get_attachment':
                $result = $this->redmine->attachment($this->intArg($arguments, 'attachment_id'));
                break;
            case 'redmine_upload_attachment':
                $result = $this->redmine->uploadAttachment($arguments);
                break;
            case 'redmine_download_attachment':
                $result = $this->redmine->downloadAttachment(
                    $this->intArg($arguments, 'attachment_id'),
                    $this->stringArg($arguments, 'path'),
                    $this->boolArg($arguments, 'include_base64', false),
                    $this->intArg($arguments, 'max_base64_bytes', 262144)
                );
                break;
            case 'redmine_update_attachment':
                $result = $this->redmine->updateAttachment($this->intArg($arguments, 'attachment_id'), $this->objectArg($arguments, 'fields'));
                break;
            case 'redmine_send_helpdesk_response':
                $result = $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options'));
                break;
            default:
                throw new RuntimeException('Unknown tool: ' . $name);
        }

        $encoded = json_encode($this->redactSensitive($result), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if ($encoded === false) {
            throw new RuntimeException('Could not encode tool result.');
        }

        return [
            'content' => [
                [
                    'type' => 'text',
                    'text' => $encoded,
                ],
            ],
        ];
    }

    /**
     * @param array<string,mixed> $arguments
     *
     * @return array<string,mixed>
     */
    private function objectArg(array $arguments, string $key): array
    {
        return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
    }

    /**
     * @param array<string,mixed> $arguments
     *
     * @return array<string,mixed>
     */
    private function issueFieldsArg(array $arguments): array
    {
        $fields = $this->objectArg($arguments, 'fields');
        foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) {
            if (array_key_exists($key, $arguments)) {
                $fields[$key] = $arguments[$key];
            }
        }

        return $fields;
    }

    /**
     * @return array<string,mixed>
     */
    private function findProject(string $query, int $limit): array
    {
        $limit = max(1, min(25, $limit));
        $projectsResponse = $this->redmine->listProjects(['limit' => 100]);
        $projects = is_array($projectsResponse['projects'] ?? null) ? $projectsResponse['projects'] : [];
        $matches = [];

        foreach ($projects as $project) {
            if (!is_array($project)) {
                continue;
            }
            $match = $this->projectMatch($project, $query);
            if ($match !== null) {
                $matches[] = $match;
            }
        }

        usort($matches, static function (array $a, array $b): int {
            $scoreCompare = ($b['score'] <=> $a['score']);
            if ($scoreCompare !== 0) {
                return $scoreCompare;
            }

            $idCompare = ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
            if ($idCompare !== 0) {
                return $idCompare;
            }

            return strcmp((string) $a['project_id_to_use'], (string) $b['project_id_to_use']);
        });
        $matches = array_slice($matches, 0, $limit);
        $recommended = null;
        if (count($matches) === 1 || (isset($matches[0], $matches[1]) && $matches[0]['score'] > $matches[1]['score'])) {
            $recommended = $matches[0]['project_id_to_use'] ?? null;
        }

        return [
            'query' => $query,
            'recommended_project_id' => $recommended,
            'matches' => $matches,
        ];
    }

    /**
     * @param array<string,mixed> $project
     *
     * @return array<string,mixed>|null
     */
    private function projectMatch(array $project, string $query): ?array
    {
        $normalizedQuery = $this->normalizeProjectText($query);
        $id = $project['id'] ?? null;
        $identifier = trim((string) ($project['identifier'] ?? ''));
        $name = trim((string) ($project['name'] ?? ''));
        $normalizedId = $id === null ? '' : $this->normalizeProjectText((string) $id);
        $normalizedIdentifier = $this->normalizeProjectText($identifier);
        $normalizedName = $this->normalizeProjectText($name);
        $score = 0;
        $reason = '';

        if ($normalizedId !== '' && $normalizedQuery === $normalizedId) {
            $score = 100;
            $reason = 'exact_id';
        } elseif ($normalizedIdentifier !== '' && $normalizedQuery === $normalizedIdentifier) {
            $score = 100;
            $reason = 'exact_identifier';
        } elseif ($normalizedName !== '' && $normalizedQuery === $normalizedName) {
            $score = 90;
            $reason = 'exact_name';
        } elseif ($normalizedIdentifier !== '' && str_starts_with($normalizedIdentifier, $normalizedQuery)) {
            $score = 80;
            $reason = 'identifier_prefix';
        } elseif ($normalizedName !== '' && str_starts_with($normalizedName, $normalizedQuery)) {
            $score = 70;
            $reason = 'name_prefix';
        } elseif ($normalizedIdentifier !== '' && str_contains($normalizedIdentifier, $normalizedQuery)) {
            $score = 60;
            $reason = 'identifier_contains';
        } elseif ($normalizedName !== '' && str_contains($normalizedName, $normalizedQuery)) {
            $score = 50;
            $reason = 'name_contains';
        } else {
            return null;
        }

        return [
            'id' => $id,
            'identifier' => $identifier,
            'name' => $name,
            'score' => $score,
            'match_reason' => $reason,
            'project_id_to_use' => $identifier !== '' ? $identifier : $id,
        ];
    }

    private function normalizeProjectText(string $value): string
    {
        return strtolower(trim($value));
    }

    /**
     * @param array<string,mixed> $arguments
     */
    private function stringArg(array $arguments, string $key): string
    {
        $value = trim((string) ($arguments[$key] ?? ''));
        if ($value === '') {
            throw new RuntimeException($key . ' is required.');
        }

        return $value;
    }

    /**
     * @param array<string,mixed> $arguments
     */
    private function intArg(array $arguments, string $key, ?int $default = null): int
    {
        if (!isset($arguments[$key])) {
            if ($default !== null) {
                return $default;
            }
            throw new RuntimeException($key . ' is required.');
        }

        return (int) $arguments[$key];
    }

    /**
     * @param array<string,mixed> $arguments
     */
    private function boolArg(array $arguments, string $key, bool $default = false): bool
    {
        if (!isset($arguments[$key])) {
            return $default;
        }
        if (is_bool($arguments[$key])) {
            return $arguments[$key];
        }

        return in_array(strtolower((string) $arguments[$key]), ['1', 'true', 'yes', 'on'], true);
    }

    /**
     * @param array<string,mixed> $arguments
     */
    private function projectIdArg(array $arguments, string $key): int|string
    {
        if (!isset($arguments[$key])) {
            throw new RuntimeException($key . ' is required.');
        }
        if (is_int($arguments[$key])) {
            return $arguments[$key];
        }

        return $this->stringArg($arguments, $key);
    }

    /**
     * @param array<string,mixed> $arguments
     * @param array<int,string> $default
     *
     * @return array<int,string>
     */
    private function stringListArg(array $arguments, string $key, array $default): array
    {
        if (!isset($arguments[$key]) || !is_array($arguments[$key])) {
            return $default;
        }

        return array_values(array_filter(array_map('strval', $arguments[$key])));
    }

    /**
     * @param array<string,mixed> $context
     * @param array<string,mixed> $params
     */
    private function logCall(
        array $context,
        string $method,
        array $params,
        bool $ok,
        float $started,
        ?string $error = null
    ): void {
        $record = [
            'transport' => $context['transport'] ?? 'unknown',
            'client_ip' => $context['client_ip'] ?? null,
            'method' => $method,
            'params' => $this->redactSensitive($params),
            'ok' => $ok,
            'duration_ms' => (int) round((microtime(true) - $started) * 1000),
        ];
        if (isset($params['name'])) {
            $record['tool_name'] = $params['name'];
            $arguments = $params['arguments'] ?? null;
            $record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
        }
        if ($error !== null) {
            $record['error'] = $error;
        }

        $this->logger->log($record);
    }

    /**
     * @param mixed $value
     *
     * @return mixed
     */
    private function redactSensitive($value)
    {
        if (!is_array($value)) {
            return $value;
        }

        $redacted = [];
        foreach ($value as $key => $item) {
            if (is_string($key) && $this->isSensitiveKey($key)) {
                $redacted[$key] = '[redacted]';
                continue;
            }

            $redacted[$key] = $this->redactSensitive($item);
        }

        return $redacted;
    }

    private function isSensitiveKey(string $key): bool
    {
        $normalized = strtolower(str_replace(['-', '_'], '', $key));

        return in_array($normalized, [
            'apikey',
            'authorization',
            'bearertoken',
            'password',
            'secret',
            'token',
        ], true);
    }
}
