<?php

declare(strict_types=1);

namespace RedMCP;

use Redmine\Client\Client;
use Redmine\Client\NativeCurlClient;
use Redmine\Http\HttpClient;
use Redmine\Http\HttpFactory;
use Redmine\Serializer\PathSerializer;
use RuntimeException;
use SimpleXMLElement;
use Throwable;

final class RedmineClient
{
    private Client $client;

    public function __construct(Client $client)
    {
        $this->client = $client;
    }

    public static function fromCredentials(string $url, string $apiKeyOrUsername, ?string $password = null): self
    {
        return new self(new NativeCurlClient($url, $apiKeyOrUsername, $password));
    }

    /**
     * List Redmine issues.
     *
     * @param array<string,mixed> $params Standard Redmine issue list filters.
     *
     * @return array<string,mixed>
     */
    public function issues(array $params = []): array
    {
        return $this->listIssues($params);
    }

    /**
     * List Redmine issues.
     *
     * @param array<string,mixed> $params Standard Redmine issue list filters.
     *
     * @return array<string,mixed>
     */
    public function listIssues(array $params = []): array
    {
        $response = $this->client->getApi('issue')->list($params);
        if (!is_array($response)) {
            throw new RuntimeException('Could not list issues: ' . $this->stringifyError($response));
        }

        return $response;
    }

    /**
     * Alias for listIssues() that makes Redmine's built-in issue filters
     * explicit at call sites.
     *
     * Useful filters include project_id, tracker_id, status_id,
     * assigned_to_id, author_id, category_id, fixed_version_id, query_id,
     * created_on, updated_on, sort, offset, and limit.
     *
     * @param array<string,mixed> $filters Standard Redmine issue list filters.
     *
     * @return array<string,mixed>
     */
    public function filterIssues(array $filters = []): array
    {
        return $this->listIssues($filters);
    }

    /**
     * Search Redmine using the built-in /search.json endpoint.
     *
     * Typical params include project_id, all_words, titles_only, scope,
     * open_issues, issues, projects, news, documents, changesets, wiki_pages,
     * messages, offset, and limit.
     *
     * @param array<string,mixed> $params Standard Redmine search params.
     *
     * @return array<string,mixed>
     */
    public function search(string $query, array $params = []): array
    {
        $query = trim($query);
        if ($query === '') {
            throw new RuntimeException('Searching Redmine requires a non-empty query.');
        }

        return $this->getJson('/search', ['q' => $query] + $params) ?? [];
    }

    /**
     * Search only issues using Redmine's built-in /search.json endpoint.
     *
     * @param array<string,mixed> $params Additional Redmine search params.
     *
     * @return array<string,mixed>
     */
    public function searchIssues(string $query, array $params = []): array
    {
        return $this->search($query, ['issues' => '1'] + $params);
    }

    /**
     * List Redmine projects.
     *
     * @param array<string,mixed> $params Standard Redmine project list params.
     *
     * @return array<string,mixed>
     */
    public function projects(array $params = []): array
    {
        return $this->listProjects($params);
    }

    /**
     * List Redmine projects.
     *
     * @param array<string,mixed> $params Standard Redmine project list params.
     *
     * @return array<string,mixed>
     */
    public function listProjects(array $params = []): array
    {
        return $this->getJson('/projects', $params) ?? [];
    }

    /**
     * Fetch a Redmine project by numeric id or identifier.
     *
     * @param array<string,mixed> $params Standard Redmine project show params.
     *
     * @return array<string,mixed>
     */
    public function project(int|string $projectId, array $params = []): array
    {
        $projectId = trim((string) $projectId);
        if ($projectId === '') {
            throw new RuntimeException('Fetching a project requires a project id or identifier.');
        }

        $response = $this->getJson('/projects/' . rawurlencode($projectId), $params);
        if (!is_array($response)) {
            throw new RuntimeException('Could not fetch project ' . $projectId . '.');
        }

        return $response['project'] ?? $response;
    }

    /**
     * List Redmine users.
     *
     * @param array<string,mixed> $params Standard Redmine user list params.
     *
     * @return array<string,mixed>
     */
    public function users(array $params = []): array
    {
        return $this->listUsers($params);
    }

    /**
     * List Redmine users.
     *
     * @param array<string,mixed> $params Standard Redmine user list params.
     *
     * @return array<string,mixed>
     */
    public function listUsers(array $params = []): array
    {
        return $this->getJson('/users', $params) ?? [];
    }

    /**
     * Fetch one Redmine user.
     *
     * @param array<string,mixed> $params Standard Redmine user show params.
     *
     * @return array<string,mixed>
     */
    public function user(int $userId, array $params = []): array
    {
        if ($userId <= 0) {
            throw new RuntimeException('Fetching a user requires a positive user id.');
        }

        $response = $this->getJson('/users/' . rawurlencode((string) $userId), $params);
        if (!is_array($response)) {
            throw new RuntimeException('Could not fetch user #' . $userId . '.');
        }

        return $response['user'] ?? $response;
    }

    /**
     * List memberships for a Redmine project.
     *
     * @param array<string,mixed> $params Standard Redmine membership list params.
     *
     * @return array<string,mixed>
     */
    public function projectMemberships(int|string $projectId, array $params = []): array
    {
        $projectId = trim((string) $projectId);
        if ($projectId === '') {
            throw new RuntimeException('Fetching project memberships requires a project id or identifier.');
        }

        return $this->getJson('/projects/' . rawurlencode($projectId) . '/memberships', $params) ?? [];
    }

    /**
     * Fetch a normal Redmine issue.
     *
     * @param array<int,string> $issueIncludes Standard Redmine issue includes:
     *                                         journals, attachments, children,
     *                                         relations, changesets.
     *
     * @return array<string,mixed>
     */
    public function issue(int $issueId, array $issueIncludes = ['journals', 'attachments']): array
    {
        $issueResponse = $this->client->getApi('issue')->show($issueId, ['include' => $issueIncludes]);
        if (!is_array($issueResponse)) {
            throw new RuntimeException('Could not fetch issue #' . $issueId . ': ' . $this->stringifyError($issueResponse));
        }

        return $issueResponse['issue'] ?? $issueResponse;
    }

    /**
     * Create a Redmine issue.
     *
     * Typical fields include project_id, subject, description, tracker_id,
     * status_id, priority_id, assigned_to_id, category_id, parent_issue_id,
     * parent_id, uploads, due_date, and start_date.
     *
     * @param array<string,mixed> $fields
     *
     * @return array<string,mixed>
     */
    public function createIssue(array $fields): array
    {
        if (!isset($fields['project_id']) || !isset($fields['subject'])) {
            throw new RuntimeException('Creating an issue requires at least project_id and subject.');
        }

        $response = $this->postJson('/issues', ['issue' => $fields]);

        return is_array($response['issue'] ?? null) ? $response['issue'] : $response;
    }

    /**
     * Update a Redmine issue.
     *
     * By default this uses the normal Redmine issue REST API and does not send
     * a Helpdesk email. To send a customer-visible Helpdesk response, pass
     * ['send_helpdesk_email' => true] with a notes field, or call
     * sendHelpdeskIssueResponse() directly.
     *
     * Typical fields include notes, subject, status_id, priority_id,
     * assigned_to_id, private_notes, parent_issue_id, parent_id, category_id,
     * uploads, due_date, and tracker_id.
     *
     * @param array<string,mixed> $fields
     */
    public function updateIssue(int $issueId, array $fields, array $options = []): bool
    {
        if ($fields === []) {
            throw new RuntimeException('Updating an issue requires at least one field.');
        }

        if (!empty($options['send_helpdesk_email'])) {
            if (!isset($fields['notes']) || trim((string) $fields['notes']) === '') {
                throw new RuntimeException('Sending a Helpdesk email requires a non-empty notes field.');
            }
            if (!empty($fields['private_notes'])) {
                throw new RuntimeException('A private note cannot be sent as a Helpdesk email.');
            }

            $issueFields = $fields;
            unset($issueFields['notes'], $issueFields['private_notes']);
            if ($issueFields !== []) {
                $this->updateIssue($issueId, $issueFields);
            }

            $this->sendHelpdeskIssueResponse($issueId, (string) $fields['notes'], $options);

            return true;
        }

        $this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]);

        return true;
    }

    /**
     * @return array<string,mixed>
     */
    public function issueWithStructure(int $issueId): array
    {
        return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']);
    }

    /**
     * @return array<string,mixed>
     */
    public function listIssueChildren(int $issueId, array $params = []): array
    {
        return $this->listIssues(['parent_id' => $issueId] + $params);
    }

    public function setIssueParent(int $issueId, int $parentIssueId): bool
    {
        if ($parentIssueId <= 0) {
            throw new RuntimeException('Setting an issue parent requires a positive parent_issue_id.');
        }

        return $this->updateIssue($issueId, ['parent_issue_id' => $parentIssueId]);
    }

    public function clearIssueParent(int $issueId): bool
    {
        return $this->updateIssue($issueId, ['parent_issue_id' => null]);
    }

    /**
     * @return array<string,mixed>
     */
    public function listIssueRelations(int $issueId): array
    {
        return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? [];
    }

    /**
     * @return array<string,mixed>
     */
    public function issueRelation(int $relationId): array
    {
        if ($relationId <= 0) {
            throw new RuntimeException('Fetching an issue relation requires a positive relation id.');
        }

        $response = $this->getJson('/relations/' . rawurlencode((string) $relationId));
        if (!is_array($response)) {
            throw new RuntimeException('Could not fetch issue relation #' . $relationId . '.');
        }

        return $response['relation'] ?? $response;
    }

    /**
     * @param array<string,mixed> $fields
     *
     * @return array<string,mixed>
     */
    public function createIssueRelation(int $issueId, array $fields): array
    {
        if (!isset($fields['issue_to_id']) || (int) $fields['issue_to_id'] <= 0) {
            throw new RuntimeException('Creating an issue relation requires a positive issue_to_id.');
        }

        $fields += ['relation_type' => 'relates'];
        $response = $this->postJson('/issues/' . rawurlencode((string) $issueId) . '/relations', ['relation' => $fields]);

        return is_array($response['relation'] ?? null) ? $response['relation'] : $response;
    }

    public function removeIssueRelation(int $relationId): bool
    {
        if ($relationId <= 0) {
            throw new RuntimeException('Removing an issue relation requires a positive relation id.');
        }

        $this->deleteJson('/relations/' . rawurlencode((string) $relationId));

        return true;
    }

    /**
     * @return array<string,mixed>
     */
    public function listProjectIssueCategories(int|string $projectId): array
    {
        $projectId = trim((string) $projectId);
        if ($projectId === '') {
            throw new RuntimeException('Listing issue categories requires a project id or identifier.');
        }

        return $this->getJson('/projects/' . rawurlencode($projectId) . '/issue_categories') ?? [];
    }

    /**
     * @return array<string,mixed>
     */
    public function issueCategory(int $categoryId): array
    {
        if ($categoryId <= 0) {
            throw new RuntimeException('Fetching an issue category requires a positive category id.');
        }

        $response = $this->getJson('/issue_categories/' . rawurlencode((string) $categoryId));
        if (!is_array($response)) {
            throw new RuntimeException('Could not fetch issue category #' . $categoryId . '.');
        }

        return $response['issue_category'] ?? $response;
    }

    /**
     * @param array<string,mixed> $fields
     *
     * @return array<string,mixed>
     */
    public function createIssueCategory(int|string $projectId, array $fields): array
    {
        $projectId = trim((string) $projectId);
        if ($projectId === '') {
            throw new RuntimeException('Creating an issue category requires a project id or identifier.');
        }
        if (!isset($fields['name']) || trim((string) $fields['name']) === '') {
            throw new RuntimeException('Creating an issue category requires a non-empty name.');
        }

        $response = $this->postJson('/projects/' . rawurlencode($projectId) . '/issue_categories', ['issue_category' => $fields]);

        return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
    }

    /**
     * @param array<string,mixed> $fields
     *
     * @return array<string,mixed>
     */
    public function updateIssueCategory(int $categoryId, array $fields): array
    {
        if ($fields === []) {
            throw new RuntimeException('Updating an issue category requires at least one field.');
        }

        $response = $this->putJson('/issue_categories/' . rawurlencode((string) $categoryId), ['issue_category' => $fields]);

        return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
    }

    /**
     * @return array<string,mixed>
     */
    public function attachment(int $attachmentId): array
    {
        if ($attachmentId <= 0) {
            throw new RuntimeException('Fetching an attachment requires a positive attachment id.');
        }

        $response = $this->getJson('/attachments/' . rawurlencode((string) $attachmentId));
        if (!is_array($response)) {
            throw new RuntimeException('Could not fetch attachment #' . $attachmentId . '.');
        }

        return $response['attachment'] ?? $response;
    }

    /**
     * @param array<string,mixed> $source
     *
     * @return array<string,mixed>
     */
    public function uploadAttachment(array $source): array
    {
        $source = $this->normalizeAttachmentUploadSource($source);
        $filename = isset($source['filename']) ? trim((string) $source['filename']) : '';
        $contentType = trim((string) ($source['content_type'] ?? 'application/octet-stream'));
        if ($contentType === '') {
            $contentType = 'application/octet-stream';
        }

        if (isset($source['path'])) {
            $path = (string) $source['path'];
            if (!is_file($path) || !is_readable($path)) {
                throw new RuntimeException('Uploading an attachment from path requires a readable local file.');
            }
            $bytes = file_get_contents($path);
            if ($bytes === false) {
                throw new RuntimeException('Could not read attachment file: ' . $path);
            }
            if ($filename === '') {
                $filename = basename($path);
            }
        } elseif (isset($source['base64_content'])) {
            if ($filename === '') {
                throw new RuntimeException('Uploading base64 attachment content requires filename.');
            }
            $decoded = base64_decode((string) $source['base64_content'], true);
            if ($decoded === false) {
                throw new RuntimeException('Attachment base64_content is not valid base64.');
            }
            $bytes = $decoded;
        } else {
            throw new RuntimeException('Uploading an attachment requires either path or base64_content.');
        }

        if ($filename === '') {
            throw new RuntimeException('Uploading an attachment requires filename.');
        }

        $response = $this->rawRequest(
            'POST',
            PathSerializer::create('/uploads.json', ['filename' => $filename])->getPath(),
            'application/octet-stream',
            $bytes
        );
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Redmine upload failed with HTTP ' . $status . ': ' . $response->getContent());
        }

        $decoded = $this->decodeJsonResponse($response, 'Redmine upload');
        $upload = is_array($decoded['upload'] ?? null) ? $decoded['upload'] : $decoded;
        if (isset($source['description'])) {
            $upload['description'] = (string) $source['description'];
        }
        $upload += [
            'filename' => $filename,
            'content_type' => $contentType,
        ];

        return $upload;
    }

    /**
     * @param array<string,mixed> $source
     *
     * @return array<string,mixed>
     */
    private function normalizeAttachmentUploadSource(array $source): array
    {
        if (isset($source['file']) && is_array($source['file'])) {
            $file = $source['file'];
            unset($source['file']);
            foreach ([
                'path' => 'path',
                'filename' => 'filename',
                'name' => 'filename',
                'content_type' => 'content_type',
                'mime_type' => 'content_type',
                'mimeType' => 'content_type',
                'media_type' => 'content_type',
                'description' => 'description',
                'base64_content' => 'base64_content',
                'base64' => 'base64_content',
                'data' => 'base64_content',
                'blob' => 'base64_content',
                'data_url' => 'data_url',
                'url' => 'data_url',
            ] as $from => $to) {
                if (!isset($source[$to]) && isset($file[$from])) {
                    $source[$to] = $file[$from];
                }
            }
        }

        foreach ([
            'name' => 'filename',
            'mime_type' => 'content_type',
            'mimeType' => 'content_type',
            'media_type' => 'content_type',
            'base64' => 'base64_content',
            'data' => 'base64_content',
            'blob' => 'base64_content',
        ] as $from => $to) {
            if (!isset($source[$to]) && isset($source[$from])) {
                $source[$to] = $source[$from];
            }
        }

        if (isset($source['data_url']) || (isset($source['base64_content']) && str_starts_with((string) $source['base64_content'], 'data:'))) {
            $dataUrl = (string) ($source['data_url'] ?? $source['base64_content']);
            $parsed = $this->parseAttachmentDataUrl($dataUrl);
            $source['base64_content'] = $parsed['base64_content'];
            if (!isset($source['content_type'])) {
                $source['content_type'] = $parsed['content_type'];
            }
            if (!isset($source['filename']) || trim((string) $source['filename']) === '') {
                $source['filename'] = $this->defaultAttachmentFilename((string) $source['content_type']);
            }
        }

        return $source;
    }

    /**
     * @return array{content_type:string,base64_content:string}
     */
    private function parseAttachmentDataUrl(string $dataUrl): array
    {
        if (!preg_match('/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s', $dataUrl, $matches)) {
            throw new RuntimeException('Attachment data_url must be a base64 data URL.');
        }

        $contentType = trim($matches[1] !== '' ? $matches[1] : 'application/octet-stream');

        return [
            'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream',
            'base64_content' => $matches[2],
        ];
    }

    private function defaultAttachmentFilename(string $contentType): string
    {
        return match (strtolower($contentType)) {
            'application/pdf' => 'attachment.pdf',
            'text/plain' => 'attachment.txt',
            'text/csv' => 'attachment.csv',
            'application/json' => 'attachment.json',
            'image/jpeg' => 'attachment.jpg',
            'image/png' => 'attachment.png',
            'image/gif' => 'attachment.gif',
            default => 'attachment.bin',
        };
    }

    /**
     * @return array<string,mixed>
     */
    public function updateAttachment(int $attachmentId, array $fields): array
    {
        if ($fields === []) {
            throw new RuntimeException('Updating an attachment requires at least one field.');
        }

        $response = $this->putJson('/attachments/' . rawurlencode((string) $attachmentId), ['attachment' => $fields]);

        return is_array($response['attachment'] ?? null) ? $response['attachment'] : $response;
    }

    /**
     * @return array<string,mixed>
     */
    public function downloadAttachment(int $attachmentId, string $destinationPath, bool $includeBase64 = false, int $maxBase64Bytes = 262144): array
    {
        $destinationPath = $this->safeDownloadPath($destinationPath);
        $response = $this->rawRequest('GET', '/attachments/download/' . rawurlencode((string) $attachmentId));
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Redmine attachment download failed with HTTP ' . $status . ': ' . $response->getContent());
        }

        $bytes = $response->getContent();
        if (file_put_contents($destinationPath, $bytes) === false) {
            throw new RuntimeException('Could not write attachment download to ' . $destinationPath . '.');
        }

        $result = [
            'attachment_id' => $attachmentId,
            'path' => $destinationPath,
            'bytes' => strlen($bytes),
            'content_type' => $response->getContentType(),
        ];
        if ($includeBase64 && strlen($bytes) <= $maxBase64Bytes) {
            $result['base64_content'] = base64_encode($bytes);
        } elseif ($includeBase64) {
            $result['base64_omitted'] = true;
            $result['base64_limit_bytes'] = $maxBase64Bytes;
        }

        return $result;
    }

    /**
     * Send a Helpdesk email response for an existing Helpdesk-backed issue.
     *
     * This uses the RedmineUP Helpdesk API endpoint that corresponds to sending
     * a note with the Helpdesk UI's "Send note" checkbox enabled.
     *
     * @param array<string,mixed> $options Optional to_address, cc_address,
     *                                     bcc_address, and status_id fields.
     *
     * @return array<string,mixed>
     */
    public function sendHelpdeskIssueResponse(int $issueId, string $content, array $options = []): array
    {
        if (trim($content) === '') {
            throw new RuntimeException('Sending a Helpdesk response requires non-empty content.');
        }

        $message = [
            'issue_id' => $issueId,
            'content' => $content,
        ];
        if (isset($options['status_id'])) {
            $message['status_id'] = $options['status_id'];
        }

        $payload = ['message' => $message];
        foreach (['to_address', 'cc_address', 'bcc_address'] as $key) {
            if (isset($options[$key])) {
                $payload[$key] = $options[$key];
            }
        }

        $response = $this->postJson('/helpdesk/email_note', $payload);
        if (!isset($response['message']) || !is_array($response['message'])) {
            throw new RuntimeException('Helpdesk email response returned unexpected JSON.');
        }

        return $response['message'];
    }

    /**
     * Delete a Redmine issue.
     */
    public function deleteIssue(int $issueId): bool
    {
        $issueApi = $this->client->getApi('issue');
        $issueApi->remove($issueId);
        $this->assertLastApiResponseSucceeded($issueApi, 'delete issue #' . $issueId);

        return true;
    }

    /**
     * Alias for deleteIssue().
     */
    public function removeIssue(int $issueId): bool
    {
        return $this->deleteIssue($issueId);
    }

    /**
     * @param array<int,string> $issueIncludes Standard Redmine issue includes.
     *
     * @return array<string,mixed>
     */
    public function issueWithHelpdesk(int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments']): array
    {
        return $this->getIssueWithHelpdeskContext($issueId, $messageLimit, $issueIncludes);
    }

    /**
     * @param array<int,string> $issueIncludes Standard Redmine issue includes.
     *
     * @return array<string,mixed>
     */
    public function getIssueWithHelpdeskContext(
        int $issueId,
        int $messageLimit = 100,
        array $issueIncludes = ['journals', 'attachments']
    ): array
    {
        $issue = $this->issue($issueId, $issueIncludes);

        $ticket = $this->helpdeskTicketByIssue($issueId);
        $messages = $this->helpdeskMessagesByIssue($issueId, $messageLimit);

        return [
            'issue' => $issue,
            'helpdesk' => [
                'available' => $ticket !== null || $messages !== [],
                'ticket' => $ticket,
                'journal_messages' => $messages,
            ],
            'meta' => [
                'issue_id' => $issueId,
                'issue_includes' => array_values($issueIncludes),
                'helpdesk_sources' => [
                    'ticket' => '/helpdesk_search/issues/' . $issueId . '/ticket',
                    'messages' => '/helpdesk_search/issues/' . $issueId . '/messages',
                ],
            ],
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    public function helpdeskTicketByIssue(int $issueId): ?array
    {
        return $this->getHelpdeskTicketByIssue($issueId);
    }

    /**
     * @return array<string,mixed>|null
     */
    public function getHelpdeskTicketByIssue(int $issueId): ?array
    {
        $response = $this->getJson('/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/ticket', [], [403, 404]);
        if (!is_array($response) || !isset($response['helpdesk_ticket']) || !is_array($response['helpdesk_ticket'])) {
            return null;
        }

        return $response['helpdesk_ticket'];
    }

    /**
     * @return array<int,mixed>
     */
    public function helpdeskMessagesByIssue(int $issueId, int $limit = 100): array
    {
        return $this->getHelpdeskMessagesByIssue($issueId, $limit);
    }

    /**
     * @return array<int,mixed>
     */
    public function getHelpdeskMessagesByIssue(int $issueId, int $limit = 100): array
    {
        $response = $this->getJson(
            '/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/messages',
            ['limit' => max(1, min($limit, 200))],
            [403, 404]
        );
        if (!is_array($response) || !isset($response['journal_messages']) || !is_array($response['journal_messages'])) {
            return [];
        }

        return $response['journal_messages'];
    }

    /**
     * @param array<string,mixed> $params
     *
     * @return array<string,mixed>|null
     */
    private function getJson(string $path, array $params = [], array $nullStatuses = []): ?array
    {
        if (!$this->client instanceof HttpClient) {
            throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
        }

        $requestPath = $this->buildPath($path, $params);
        $response = $this->client->request(HttpFactory::makeJsonRequest('GET', $requestPath));
        $status = $response->getStatusCode();

        if (in_array($status, $nullStatuses, true)) {
            return null;
        }
        if ($status >= 400) {
            throw new RuntimeException('Redmine GET ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
        }

        $body = $response->getContent();
        if ($body === '') {
            return [];
        }

        try {
            $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (Throwable $exception) {
            throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.', 0, $exception);
        }

        if (!is_array($decoded)) {
            throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.');
        }

        return $decoded;
    }

    /**
     * @param array<string,mixed> $payload
     *
     * @return array<string,mixed>
     */
    private function postJson(string $path, array $payload): array
    {
        $requestPath = $this->buildPath($path, []);
        $encoded = json_encode($payload);
        if ($encoded === false) {
            throw new RuntimeException('Could not encode Redmine POST payload.');
        }

        $response = $this->rawRequest('POST', $requestPath, 'application/json', $encoded);
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
        }

        return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath);
    }

    /**
     * @param array<string,mixed> $payload
     *
     * @return array<string,mixed>
     */
    private function putJson(string $path, array $payload): array
    {
        $requestPath = $this->buildPath($path, []);
        $encoded = json_encode($payload);
        if ($encoded === false) {
            throw new RuntimeException('Could not encode Redmine PUT payload.');
        }

        $response = $this->rawRequest('PUT', $requestPath, 'application/json', $encoded);
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Redmine PUT ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
        }

        return $this->decodeJsonResponse($response, 'Redmine PUT ' . $requestPath);
    }

    private function deleteJson(string $path): void
    {
        $requestPath = $this->buildPath($path, []);
        $response = $this->rawRequest('DELETE', $requestPath);
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Redmine DELETE ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
        }
    }

    private function rawRequest(string $method, string $path, string $contentType = '', string $content = ''): \Redmine\Http\Response
    {
        if (!$this->client instanceof HttpClient) {
            throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
        }

        return $this->client->request(HttpFactory::makeRequest($method, $path, $contentType, $content));
    }

    /**
     * @return array<string,mixed>
     */
    private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array
    {
        $body = $response->getContent();
        if ($body === '') {
            return [];
        }

        try {
            $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (Throwable $exception) {
            throw new RuntimeException($action . ' returned invalid JSON.', 0, $exception);
        }

        if (!is_array($decoded)) {
            throw new RuntimeException($action . ' returned invalid JSON.');
        }

        return $decoded;
    }

    /**
     * @param array<string,mixed> $params
     */
    private function buildPath(string $path, array $params): string
    {
        return PathSerializer::create($path . '.json', $params)->getPath();
    }

    private function safeDownloadPath(string $path): string
    {
        $path = trim($path);
        if ($path === '' || str_contains($path, "\0")) {
            throw new RuntimeException('Attachment download requires a safe local destination path.');
        }

        $directory = dirname($path);
        $realDirectory = realpath($directory);
        if ($realDirectory === false || !is_dir($realDirectory)) {
            throw new RuntimeException('Attachment download destination directory does not exist: ' . $directory);
        }

        $resolved = $realDirectory . DIRECTORY_SEPARATOR . basename($path);
        $repoRoot = realpath(dirname(__DIR__, 2));
        $tmpRoot = realpath(sys_get_temp_dir());
        foreach (array_filter([$repoRoot, $tmpRoot]) as $allowedRoot) {
            if ($resolved === $allowedRoot || str_starts_with($resolved, $allowedRoot . DIRECTORY_SEPARATOR)) {
                return $resolved;
            }
        }

        throw new RuntimeException('Attachment downloads must write under /tmp or the repository tree.');
    }

    /**
     * @param mixed $api
     */
    private function assertLastApiResponseSucceeded($api, string $action): void
    {
        if (!method_exists($api, 'getLastResponse')) {
            return;
        }

        $response = $api->getLastResponse();
        $status = $response->getStatusCode();
        if ($status >= 400) {
            throw new RuntimeException('Could not ' . $action . ': HTTP ' . $status . ': ' . $response->getContent());
        }
    }

    /**
     * @param mixed $response
     *
     * @return array<string,mixed>
     */
    private function xmlResponseToArray($response): array
    {
        if ($response instanceof SimpleXMLElement) {
            $encoded = json_encode($response);
            if ($encoded === false) {
                throw new RuntimeException('Could not encode Redmine XML response.');
            }

            $decoded = json_decode($encoded, true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        if ($response === '') {
            return [];
        }

        throw new RuntimeException('Unexpected Redmine response: ' . $this->stringifyError($response));
    }

    /**
     * @param mixed $value
     */
    private function stringifyError($value): string
    {
        if ($value === false) {
            return 'empty response';
        }
        if (is_scalar($value)) {
            return (string) $value;
        }

        return 'unexpected response';
    }
}
