#!/usr/bin/env php
<?php

declare(strict_types=1);

use RedMCP\RedmineClient;
use RedMCP\McpDispatcher;
use Redmine\Api;
use Redmine\Client\Client;
use Redmine\Http\HttpClient;
use Redmine\Http\HttpFactory;
use Redmine\Http\Request;
use Redmine\Http\Response;

require __DIR__ . '/../vendor/autoload.php';

final class RecordingClient implements Client, HttpClient
{
    /** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
    public array $requests = [];

    /** @var array<int,Response> */
    private array $responses = [];

    public function queueJson(array $payload, int $status = 200): void
    {
        $encoded = json_encode($payload);
        if ($encoded === false) {
            throw new RuntimeException('Could not encode fixture JSON.');
        }

        $this->responses[] = HttpFactory::makeResponse($status, 'application/json', $encoded);
    }

    public function queueBinary(string $content, string $contentType = 'application/octet-stream', int $status = 200): void
    {
        $this->responses[] = HttpFactory::makeResponse($status, $contentType, $content);
    }

    public function request(Request $request): Response
    {
        $this->requests[] = [
            'method' => $request->getMethod(),
            'path' => $request->getPath(),
            'content_type' => $request->getContentType(),
            'content' => $request->getContent(),
        ];

        return array_shift($this->responses) ?? HttpFactory::makeResponse(200, 'application/json', '{}');
    }

    public function getApi(string $name): Api
    {
        throw new RuntimeException('Unexpected vendor API call for ' . $name);
    }

    public function startImpersonateUser(string $username): void {}
    public function stopImpersonateUser(): void {}
    public function requestGet(string $path): bool { return false; }
    public function requestPost(string $path, string $body): bool { return false; }
    public function requestPut(string $path, string $body): bool { return false; }
    public function requestDelete(string $path): bool { return false; }
    public function getLastResponseStatusCode(): int { return 0; }
    public function getLastResponseContentType(): string { return ''; }
    public function getLastResponseBody(): string { return ''; }
}

final class RedmineStructureTest
{
    private int $assertions = 0;

    public function run(): void
    {
        $this->testCreateIssuePreservesStructureFields();
        $this->testUpdateIssuePreservesParentAndUploads();
        $this->testMcpCreateIssueAcceptsFlatIssueFields();
        $this->testMcpUpdateIssueAcceptsFlatIssueFields();
        $this->testMcpFindProjectRecommendsExactIdentifier();
        $this->testMcpFindProjectRecommendsExactName();
        $this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended();
        $this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
        $this->testAttachmentUploadSupportsPathAndBase64();
        $this->testAttachmentUploadAcceptsPdfDataUrl();
        $this->testAttachmentUploadAcceptsFileEnvelope();
        $this->testDownloadPathValidationRejectsUnsafePaths();
        $this->testDownloadAttachmentWritesSafePathAndLimitsBase64();
        $this->testMcpToolListExposesStructureToolsWithoutIssueDelete();

        fwrite(STDOUT, "OK {$this->assertions} assertions\n");
    }

    private function testCreateIssuePreservesStructureFields(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['issue' => ['id' => 123]], 201);
        $client = new RedmineClient($http);

        $result = $client->createIssue([
            'project_id' => 'fud-nohelpdesk',
            'subject' => 'Child issue',
            'parent_issue_id' => 99,
            'category_id' => 4,
            'uploads' => [
                ['token' => 'tok-1', 'filename' => 'note.txt', 'content_type' => 'text/plain'],
            ],
        ]);

        $request = $http->requests[0];
        $payload = $this->json($request['content']);
        $this->assertSame('POST', $request['method'], 'create issue uses POST');
        $this->assertSame('/issues.json', $request['path'], 'create issue uses raw JSON endpoint');
        $this->assertSame(99, $payload['issue']['parent_issue_id'], 'create issue preserves parent_issue_id');
        $this->assertSame(4, $payload['issue']['category_id'], 'create issue preserves category_id');
        $this->assertSame('tok-1', $payload['issue']['uploads'][0]['token'], 'create issue preserves upload tokens');
        $this->assertSame(123, $result['id'], 'create issue unwraps issue response');
    }

    private function testUpdateIssuePreservesParentAndUploads(): void
    {
        $http = new RecordingClient();
        $http->queueJson([], 204);
        $client = new RedmineClient($http);

        $ok = $client->updateIssue(123, [
            'parent_id' => 99,
            'category_id' => 4,
            'uploads' => [
                ['token' => 'tok-2', 'filename' => 'followup.txt'],
            ],
        ]);

        $request = $http->requests[0];
        $payload = $this->json($request['content']);
        $this->assertSame(true, $ok, 'update issue returns true on 204');
        $this->assertSame('PUT', $request['method'], 'update issue uses PUT');
        $this->assertSame('/issues/123.json', $request['path'], 'update issue uses raw JSON endpoint');
        $this->assertSame(99, $payload['issue']['parent_id'], 'update issue preserves parent_id');
        $this->assertSame('tok-2', $payload['issue']['uploads'][0]['token'], 'update issue preserves upload tokens');
    }

    private function testMcpCreateIssueAcceptsFlatIssueFields(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['issue' => ['id' => 321]], 201);
        $dispatcher = new McpDispatcher(new RedmineClient($http));

        $response = $dispatcher->handleMessage([
            'jsonrpc' => '2.0',
            'id' => 1,
            'method' => 'tools/call',
            'params' => [
                'name' => 'redmine_create_issue',
                'arguments' => [
                    'project_id' => 'quality-tracker',
                    'subject' => 'Front warehouse deadbolt key gets stuck in lock',
                    'description' => "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot",
                ],
            ],
        ]);

        if (!is_array($response) || isset($response['error'])) {
            throw new RuntimeException('Expected flat create issue call to succeed: ' . json_encode($response));
        }
        $payload = $this->json($http->requests[0]['content']);
        $this->assertSame('quality-tracker', $payload['issue']['project_id'], 'flat MCP create preserves project_id');
        $this->assertSame('Front warehouse deadbolt key gets stuck in lock', $payload['issue']['subject'], 'flat MCP create preserves subject');
        $this->assertStringContains('~HermesBot', $payload['issue']['description'], 'flat MCP create preserves multiline description');
    }

    private function testMcpUpdateIssueAcceptsFlatIssueFields(): void
    {
        $http = new RecordingClient();
        $http->queueJson([], 204);
        $dispatcher = new McpDispatcher(new RedmineClient($http));

        $response = $dispatcher->handleMessage([
            'jsonrpc' => '2.0',
            'id' => 1,
            'method' => 'tools/call',
            'params' => [
                'name' => 'redmine_update_issue',
                'arguments' => [
                    'issue_id' => 321,
                    'notes' => 'Locksmith has been scheduled.',
                    'status_id' => 2,
                    'options' => ['send_helpdesk_email' => false],
                ],
            ],
        ]);

        if (!is_array($response) || isset($response['error'])) {
            throw new RuntimeException('Expected flat update issue call to succeed: ' . json_encode($response));
        }
        $payload = $this->json($http->requests[0]['content']);
        $this->assertSame('/issues/321.json', $http->requests[0]['path'], 'flat MCP update uses requested issue id');
        $this->assertSame('Locksmith has been scheduled.', $payload['issue']['notes'], 'flat MCP update preserves notes');
        $this->assertSame(2, $payload['issue']['status_id'], 'flat MCP update preserves status_id');
        $this->assertSame(false, isset($payload['issue']['options']), 'flat MCP update does not forward options as issue field');
    }

    private function testMcpFindProjectRecommendsExactIdentifier(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['projects' => $this->projectFixtures()]);
        $dispatcher = new McpDispatcher(new RedmineClient($http));

        $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality-tracker']);

        $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact identifier produces recommendation');
        $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'exact identifier match is first');
        $this->assertSame('exact_identifier', $result['matches'][0]['match_reason'], 'exact identifier reason is reported');
        $this->assertSame('quality-tracker', $result['matches'][0]['project_id_to_use'], 'identifier is preferred project_id_to_use');
    }

    private function testMcpFindProjectRecommendsExactName(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['projects' => $this->projectFixtures()]);
        $dispatcher = new McpDispatcher(new RedmineClient($http));

        $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'Quality Tracker']);

        $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact project name produces recommendation');
        $this->assertSame('Quality Tracker', $result['matches'][0]['name'], 'exact name match is first');
        $this->assertSame('exact_name', $result['matches'][0]['match_reason'], 'exact name reason is reported');
    }

    private function testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['projects' => $this->projectFixtures()]);
        $dispatcher = new McpDispatcher(new RedmineClient($http));

        $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality']);

        $this->assertSame(null, $result['recommended_project_id'], 'ambiguous project query has no recommendation');
        $this->assertSame(2, count($result['matches']), 'ambiguous project query returns both matches');
        $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'first ambiguous match is ranked deterministically');
        $this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned');
    }

    private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['relation' => ['id' => 55]], 201);
        $client = new RedmineClient($http);

        $result = $client->createIssueRelation(10, ['issue_to_id' => 11]);
        $request = $http->requests[0];
        $payload = $this->json($request['content']);

        $this->assertSame('/issues/10/relations.json', $request['path'], 'relation create uses issue relations endpoint');
        $this->assertSame(11, $payload['relation']['issue_to_id'], 'relation create sends issue_to_id');
        $this->assertSame('relates', $payload['relation']['relation_type'], 'relation create defaults to relates');
        $this->assertSame(55, $result['id'], 'relation create unwraps relation response');

        $this->assertThrows(
            static fn() => $client->createIssueRelation(10, []),
            'issue_to_id',
            'relation create requires issue_to_id'
        );
    }

    private function testAttachmentUploadSupportsPathAndBase64(): void
    {
        $path = sys_get_temp_dir() . '/redmcp-upload-test.txt';
        file_put_contents($path, 'from path');

        $http = new RecordingClient();
        $http->queueJson(['upload' => ['token' => 'path-token']], 201);
        $http->queueJson(['upload' => ['token' => 'base64-token']], 201);
        $client = new RedmineClient($http);

        $pathResult = $client->uploadAttachment(['path' => $path, 'content_type' => 'text/plain']);
        $base64Result = $client->uploadAttachment([
            'base64_content' => base64_encode('from base64'),
            'filename' => 'base64.txt',
            'content_type' => 'text/plain',
        ]);

        $this->assertSame('path-token', $pathResult['token'], 'path upload unwraps token');
        $this->assertStringContains('filename=redmcp-upload-test.txt', $http->requests[0]['path'], 'path upload uses basename as filename');
        $this->assertSame('from path', $http->requests[0]['content'], 'path upload sends file bytes');
        $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'path upload sends bytes as Redmine upload stream');
        $this->assertSame('text/plain', $pathResult['content_type'], 'path upload preserves desired attachment content type metadata');
        $this->assertSame('base64-token', $base64Result['token'], 'base64 upload unwraps token');
        $this->assertStringContains('filename=base64.txt', $http->requests[1]['path'], 'base64 upload uses provided filename');
        $this->assertSame('from base64', $http->requests[1]['content'], 'base64 upload sends decoded bytes');
    }

    private function testAttachmentUploadAcceptsPdfDataUrl(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['upload' => ['token' => 'pdf-token']], 201);
        $client = new RedmineClient($http);

        $result = $client->uploadAttachment([
            'data_url' => 'data:application/pdf;base64,' . base64_encode('%PDF-1.4 raw pdf bytes'),
        ]);

        $this->assertSame('pdf-token', $result['token'], 'PDF data URL upload unwraps token');
        $this->assertStringContains('filename=attachment.pdf', $http->requests[0]['path'], 'PDF data URL derives a useful filename');
        $this->assertSame('%PDF-1.4 raw pdf bytes', $http->requests[0]['content'], 'PDF data URL upload sends decoded bytes');
        $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'PDF data URL upload sends bytes as Redmine upload stream');
        $this->assertSame('application/pdf', $result['content_type'], 'PDF data URL preserves PDF content type metadata');
    }

    private function testAttachmentUploadAcceptsFileEnvelope(): void
    {
        $http = new RecordingClient();
        $http->queueJson(['upload' => ['token' => 'file-token']], 201);
        $client = new RedmineClient($http);

        $result = $client->uploadAttachment([
            'file' => [
                'name' => 'quote.pdf',
                'mime_type' => 'application/pdf',
                'data' => base64_encode('%PDF-1.7 envelope bytes'),
            ],
        ]);

        $this->assertSame('file-token', $result['token'], 'file envelope upload unwraps token');
        $this->assertStringContains('filename=quote.pdf', $http->requests[0]['path'], 'file envelope uses provided name as filename');
        $this->assertSame('%PDF-1.7 envelope bytes', $http->requests[0]['content'], 'file envelope sends decoded bytes');
        $this->assertSame('application/pdf', $result['content_type'], 'file envelope preserves MIME type metadata');
    }

    private function testDownloadPathValidationRejectsUnsafePaths(): void
    {
        $client = new RedmineClient(new RecordingClient());

        $this->assertThrows(
            static fn() => $client->downloadAttachment(77, '/etc/redmcp-forbidden.txt'),
            'under /tmp or the repository tree',
            'download rejects paths outside safe roots'
        );
    }

    private function testDownloadAttachmentWritesSafePathAndLimitsBase64(): void
    {
        $destination = sys_get_temp_dir() . '/redmcp-download-test.txt';
        if (file_exists($destination)) {
            unlink($destination);
        }

        $http = new RecordingClient();
        $http->queueBinary('downloaded attachment', 'text/plain');
        $client = new RedmineClient($http);

        $result = $client->downloadAttachment(77, $destination, true, 4);

        $this->assertSame('/attachments/download/77', $http->requests[0]['path'], 'download uses Redmine attachment download endpoint');
        $this->assertSame('downloaded attachment', (string) file_get_contents($destination), 'download writes attachment bytes');
        $this->assertSame(21, $result['bytes'], 'download reports byte length');
        $this->assertSame(true, $result['base64_omitted'], 'download omits oversized base64 content');
    }

    private function testMcpToolListExposesStructureToolsWithoutIssueDelete(): void
    {
        $dispatcher = new McpDispatcher(new RedmineClient(new RecordingClient()));
        $response = $dispatcher->handleMessage([
            'jsonrpc' => '2.0',
            'id' => 1,
            'method' => 'tools/list',
        ]);
        if (!is_array($response)) {
            throw new RuntimeException('Expected tools/list response.');
        }

        $names = array_map(
            static fn(array $tool): string => (string) $tool['name'],
            $response['result']['tools']
        );

        foreach ([
            'redmine_list_issue_relations',
            'redmine_find_project',
            'redmine_get_issue_relation',
            'redmine_create_issue_relation',
            'redmine_remove_issue_relation',
            'redmine_list_issue_children',
            'redmine_set_issue_parent',
            'redmine_clear_issue_parent',
            'redmine_list_project_issue_categories',
            'redmine_get_issue_category',
            'redmine_create_issue_category',
            'redmine_update_issue_category',
            'redmine_get_attachment',
            'redmine_upload_attachment',
            'redmine_download_attachment',
            'redmine_update_attachment',
        ] as $expectedTool) {
            $this->assertContains($expectedTool, $names, $expectedTool . ' is listed');
        }

        $this->assertNotContains('redmine_delete_issue', $names, 'issue delete tool is not listed');

        $uploadTool = null;
        foreach ($response['result']['tools'] as $tool) {
            if (($tool['name'] ?? '') === 'redmine_upload_attachment') {
                $uploadTool = $tool;
                break;
            }
        }
        if (!is_array($uploadTool)) {
            throw new RuntimeException('Expected redmine_upload_attachment tool schema.');
        }
        $uploadProperties = array_keys($uploadTool['inputSchema']['properties']);
        $this->assertContains('data_url', $uploadProperties, 'upload tool advertises data_url input');
        $this->assertContains('file', $uploadProperties, 'upload tool advertises file envelope input');

        $createIssueTool = null;
        foreach ($response['result']['tools'] as $tool) {
            if (($tool['name'] ?? '') === 'redmine_create_issue') {
                $createIssueTool = $tool;
                break;
            }
        }
        if (!is_array($createIssueTool)) {
            throw new RuntimeException('Expected redmine_create_issue tool schema.');
        }
        $projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description'];
        $this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver');
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function projectFixtures(): array
    {
        return [
            ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker'],
            ['id' => 79, 'identifier' => 'quality-archive', 'name' => 'Quality Archive'],
            ['id' => 80, 'identifier' => 'warehouse', 'name' => 'Warehouse Operations'],
        ];
    }

    /**
     * @param array<string,mixed> $arguments
     * @return array<string,mixed>
     */
    private function callToolJson(McpDispatcher $dispatcher, string $name, array $arguments): array
    {
        $response = $dispatcher->handleMessage([
            'jsonrpc' => '2.0',
            'id' => 1,
            'method' => 'tools/call',
            'params' => [
                'name' => $name,
                'arguments' => $arguments,
            ],
        ]);
        if (!is_array($response) || isset($response['error'])) {
            throw new RuntimeException('Expected MCP tool call to succeed: ' . json_encode($response));
        }
        $content = $response['result']['content'][0]['text'] ?? null;
        if (!is_string($content)) {
            throw new RuntimeException('Expected MCP tool text content.');
        }

        return $this->json($content);
    }

    /**
     * @return array<string,mixed>
     */
    private function json(string $content): array
    {
        $decoded = json_decode($content, true);
        if (!is_array($decoded)) {
            throw new RuntimeException('Invalid JSON: ' . $content);
        }

        return $decoded;
    }

    /**
     * @param mixed $expected
     * @param mixed $actual
     */
    private function assertSame($expected, $actual, string $message): void
    {
        $this->assertions++;
        if ($expected === $actual) {
            return;
        }

        fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n");
        exit(1);
    }

    private function assertStringContains(string $needle, string $haystack, string $message): void
    {
        $this->assertions++;
        if (strpos($haystack, $needle) !== false) {
            return;
        }

        fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
        exit(1);
    }

    /**
     * @param array<int,string> $haystack
     */
    private function assertContains(string $needle, array $haystack, string $message): void
    {
        $this->assertions++;
        if (in_array($needle, $haystack, true)) {
            return;
        }

        fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n");
        exit(1);
    }

    /**
     * @param array<int,string> $haystack
     */
    private function assertNotContains(string $needle, array $haystack, string $message): void
    {
        $this->assertions++;
        if (!in_array($needle, $haystack, true)) {
            return;
        }

        fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n");
        exit(1);
    }

    private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void
    {
        $this->assertions++;
        try {
            $callback();
        } catch (Throwable $exception) {
            if (strpos($exception->getMessage(), $expectedMessagePart) !== false) {
                return;
            }

            fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n");
            exit(1);
        }

        fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n");
        exit(1);
    }
}

(new RedmineStructureTest())->run();
