<?php

namespace NoahVet\Reef\Test\A_Unit\Infrastructure\Phpunit\Response;

use NoahVet\Reef\Infrastructure\Phpunit\Response\EndpointTestResponse;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;

class EndpointTestResponseTest extends TestCase
{
    // --- status shortcut assertions ---

    public function testAssertOkPassesOn200(): void
    {
        $this->makeResponse(200)->assertOk();
        $this->addToAssertionCount(1);
    }

    public function testAssertOkFailsOnNon200(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponse(201)->assertOk();
    }

    public function testAssertCreatedPassesOn201(): void
    {
        $this->makeResponse(201)->assertCreated();
        $this->addToAssertionCount(1);
    }

    public function testAssertNoContentPassesOn204(): void
    {
        $this->makeResponse(204, '')->assertNoContent();
        $this->addToAssertionCount(1);
    }

    public function testAssertBadRequestPassesOn400(): void
    {
        $this->makeResponse(400)->assertBadRequest();
        $this->addToAssertionCount(1);
    }

    public function testAssertUnauthorizedPassesOn401(): void
    {
        $this->makeResponse(401)->assertUnauthorized();
        $this->addToAssertionCount(1);
    }

    public function testAssertForbiddenPassesOn403(): void
    {
        $this->makeResponse(403)->assertForbidden();
        $this->addToAssertionCount(1);
    }

    public function testAssertNotFoundPassesOn404(): void
    {
        $this->makeResponse(404)->assertNotFound();
        $this->addToAssertionCount(1);
    }

    public function testAssertUnprocessablePassesOn422(): void
    {
        $this->makeResponse(422)->assertUnprocessable();
        $this->addToAssertionCount(1);
    }

    public function testAssertStatusIsChainable(): void
    {
        $response = $this->makeResponse(200, '{"key":"value"}');

        self::assertSame($response, $response->assertOk());
    }

    // --- json() accessor ---

    public function testJsonWithoutKeyReturnsFullDecodedBody(): void
    {
        $response = $this->makeResponse(200, '{"name":"Alice","age":30}');

        self::assertSame(['name' => 'Alice', 'age' => 30], $response->json());
    }

    public function testJsonWithKeyReturnsSpecificValue(): void
    {
        $response = $this->makeResponse(200, '{"name":"Alice"}');

        self::assertSame('Alice', $response->json('name'));
    }

    public function testJsonWithMissingKeyReturnsNull(): void
    {
        $response = $this->makeResponse(200, '{"name":"Alice"}');

        self::assertNull($response->json('missing'));
    }

    public function testJsonWithInvalidBodyReturnsEmptyArray(): void
    {
        $response = $this->makeResponse(200, 'not-json');

        self::assertSame([], $response->json());
    }

    // --- assertJsonContains ---

    public function testAssertJsonContainsPassesWhenSubsetMatches(): void
    {
        $response = $this->makeResponse(200, '{"name":"Alice","role":"admin"}');

        $response->assertJsonContains(['name' => 'Alice']);
        $this->addToAssertionCount(1);
    }

    public function testAssertJsonContainsFailsWhenValueDiffers(): void
    {
        $this->expectException(AssertionFailedError::class);

        $this->makeResponse(200, '{"name":"Bob"}')
            ->assertJsonContains(['name' => 'Alice'])
        ;
    }

    public function testAssertJsonContainsFailsWhenKeyMissing(): void
    {
        $this->expectException(AssertionFailedError::class);

        $this->makeResponse(200, '{"name":"Alice"}')
            ->assertJsonContains(['role' => 'admin'])
        ;
    }

    // --- assertJsonHasKey ---

    public function testAssertJsonHasKeyPassesWhenPresent(): void
    {
        $this->makeResponse(200, '{"id":1,"name":"Alice"}')->assertJsonHasKey('id');
        $this->addToAssertionCount(1);
    }

    public function testAssertJsonHasKeyFailsWhenAbsent(): void
    {
        $this->expectException(AssertionFailedError::class);

        $this->makeResponse(200, '{"name":"Alice"}')->assertJsonHasKey('missing');
    }

    // --- assertMatchesExpectedBody / assertExcluding ---

    public function testAssertMatchesExpectedBodyPassesOnIdenticalBody(): void
    {
        $body = ['name' => 'Alice', 'role' => 'admin'];
        $response = $this->makeResponseWithExpected(
            200,
            \json_encode($body, \JSON_THROW_ON_ERROR),
            $body,
        );

        $response->assertMatchesExpectedBody();
        $this->addToAssertionCount(1);
    }

    public function testAssertMatchesExpectedBodyFailsWhenBodyDiffers(): void
    {
        $this->expectException(AssertionFailedError::class);

        $this->makeResponseWithExpected(
            200,
            '{"name":"Bob"}',
            ['name' => 'Alice'],
        )->assertMatchesExpectedBody();
    }

    public function testAssertMatchesExpectedBodyThrowsWhenNoExpectedBodyConfigured(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->expectExceptionMessageMatches('/withResponseExample/');

        $this->makeResponse(200, '{"name":"Alice"}')->assertMatchesExpectedBody();
    }

    public function testAssertExcludingStripsBlacklistedKeysBeforeComparison(): void
    {
        $response = $this->makeResponseWithExpected(
            200,
            '{"name":"Alice","id":99}',
            ['name' => 'Alice', 'id' => 1],
            ['id'],
        );

        $response->assertMatchesExpectedBody();
        $this->addToAssertionCount(1);
    }

    public function testAssertExcludingStripsAdditionalRuntimeFields(): void
    {
        $response = $this->makeResponseWithExpected(
            200,
            '{"name":"Alice","createdDate":"2024-01-01","transient":"x"}',
            ['name' => 'Alice', 'createdDate' => '2025-01-01'],
        );

        // Default blacklist already contains 'createdDate'; 'transient' is passed at runtime
        $response->assertExcluding(['transient']);
        $this->addToAssertionCount(1);
    }

    public function testAssertExcludingStripsFieldsRecursively(): void
    {
        $response = $this->makeResponseWithExpected(
            200,
            '{"items":[{"id":1,"name":"Alice"}]}',
            ['items' => [['id' => 99, 'name' => 'Alice']]],
            ['id'],
        );

        $response->assertMatchesExpectedBody();
        $this->addToAssertionCount(1);
    }

    // --- content() / assertBinaryContent() ---

    public function testContentReturnRawBody(): void
    {
        $binary = "\x89PNG\r\n\x1a\n\x00\x00";

        self::assertSame($binary, $this->makeResponse(200, $binary)->content());
    }

    public function testContentReturnsEmptyStringWhenBodyIsEmpty(): void
    {
        self::assertSame('', $this->makeResponse(204, '')->content());
    }

    public function testAssertBinaryContentPassesOnIdenticalBytes(): void
    {
        $binary = "\x00\x01\x02\x03binary";
        $this->makeResponse(200, $binary)->assertBinaryContent($binary);
        $this->addToAssertionCount(1);
    }

    public function testAssertBinaryContentFailsWhenBytesDiffer(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponse(200, 'actual')->assertBinaryContent('expected');
    }

    public function testAssertBinaryContentIsChainable(): void
    {
        $response = $this->makeResponse(200, 'data');
        self::assertSame($response, $response->assertBinaryContent('data'));
    }

    // --- header() / assertHeader() / assertHeaderContains() ---

    public function testHeaderReturnsValueWhenPresent(): void
    {
        $response = $this->makeResponseWithHeaders(200, ['Content-Type' => 'application/pdf']);
        self::assertSame('application/pdf', $response->header('Content-Type'));
    }

    public function testHeaderIsCaseInsensitive(): void
    {
        $response = $this->makeResponseWithHeaders(200, ['Content-Disposition' => 'attachment; filename="file.pdf"']);
        self::assertSame('attachment; filename="file.pdf"', $response->header('content-disposition'));
    }

    public function testHeaderReturnsNullWhenAbsent(): void
    {
        self::assertNull($this->makeResponse(200)->header('X-Missing'));
    }

    public function testAssertHeaderPassesOnExactMatch(): void
    {
        $this->makeResponseWithHeaders(200, ['Content-Type' => 'application/pdf'])
            ->assertHeader('Content-Type', 'application/pdf')
        ;
        $this->addToAssertionCount(1);
    }

    public function testAssertHeaderFailsWhenValueDiffers(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponseWithHeaders(200, ['Content-Type' => 'text/html'])
            ->assertHeader('Content-Type', 'application/pdf')
        ;
    }

    public function testAssertHeaderFailsWhenAbsent(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponse(200)->assertHeader('X-Missing', 'value');
    }

    public function testAssertHeaderIsChainable(): void
    {
        $response = $this->makeResponseWithHeaders(200, ['X-Foo' => 'bar']);
        self::assertSame($response, $response->assertHeader('X-Foo', 'bar'));
    }

    public function testAssertHeaderContainsPassesOnSubstring(): void
    {
        $this->makeResponseWithHeaders(200, ['Content-Disposition' => 'attachment; filename="report.pdf"'])
            ->assertHeaderContains('Content-Disposition', 'attachment')
        ;
        $this->addToAssertionCount(1);
    }

    public function testAssertHeaderContainsFailsWhenSubstringAbsent(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponseWithHeaders(200, ['Content-Disposition' => 'inline'])
            ->assertHeaderContains('Content-Disposition', 'attachment')
        ;
    }

    public function testAssertHeaderContainsFailsWhenHeaderAbsent(): void
    {
        $this->expectException(AssertionFailedError::class);
        $this->makeResponse(200)->assertHeaderContains('X-Missing', 'value');
    }

    public function testAssertHeaderContainsIsChainable(): void
    {
        $response = $this->makeResponseWithHeaders(200, ['Content-Type' => 'application/json; charset=utf-8']);
        self::assertSame($response, $response->assertHeaderContains('Content-Type', 'application/json'));
    }

    // --- helpers ---

    private function makeResponse(int $status, string $body = '{}'): EndpointTestResponse
    {
        return new EndpointTestResponse(new Response($body, $status), null, [
            'id', 'createdDate', 'updatedDate',
        ]);
    }

    private function makeResponseWithHeaders(int $status, array $headers, string $body = '{}'): EndpointTestResponse
    {
        return new EndpointTestResponse(new Response($body, $status, $headers), null, []);
    }

    private function makeResponseWithExpected(
        int $status,
        string $actualBody,
        array $expectedBody,
        array $blacklist = ['id', 'createdDate', 'updatedDate'],
    ): EndpointTestResponse {
        return new EndpointTestResponse(
            new Response($actualBody, $status),
            $expectedBody,
            $blacklist,
        );
    }
}
