<?php

declare(strict_types=1);

namespace App\Tests\Unit\Http;

use App\Infrastructure\Http\JsonErrorHandler;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpException;
use Slim\Exception\HttpForbiddenException;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\ServerRequestFactory;

/**
 * Production responses must never leak attacker-influenced exception
 * messages (SEC_REVIEW F26). The handler maps statuses to a fixed token
 * set, clamps invalid codes, and only adds `detail` when expose is on.
 */
final class JsonErrorHandlerTest extends TestCase
{
    public function testNotFoundReturns404Token(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler($request, new HttpNotFoundException($request, 'attacker /etc/passwd'), false, false, false);

        self::assertSame(404, $response->getStatusCode());
        self::assertSame(['error' => 'not_found'], $this->json($response));
    }

    public function testMethodNotAllowedReturns405Token(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler($request, new HttpMethodNotAllowedException($request), false, false, false);

        self::assertSame(405, $response->getStatusCode());
        self::assertSame(['error' => 'method_not_allowed'], $this->json($response));
    }

    public function testHttpExceptionMessageNeverLeaksInProduction(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        // A controller that wrapped a DB error into an HttpBadRequestException
        // would otherwise echo the raw driver message verbatim.
        $exception = new HttpBadRequestException(
            $request,
            "SQLSTATE[23000]: duplicate key value violates unique constraint 'users_email_idx'",
        );

        $response = $handler($request, $exception, false, false, false);

        self::assertSame(400, $response->getStatusCode());
        self::assertSame(['error' => 'bad_request'], $this->json($response));
    }

    public function testHttpForbiddenMapsToToken(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler($request, new HttpForbiddenException($request, 'role missing: foo'), false, false, false);

        self::assertSame(403, $response->getStatusCode());
        self::assertSame(['error' => 'forbidden'], $this->json($response));
    }

    public function testHttp500ExceptionDoesNotLeakMessage(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler(
            $request,
            new HttpInternalServerErrorException($request, 'connection to db failed at 10.0.0.5:5432'),
            false,
            false,
            false,
        );

        self::assertSame(500, $response->getStatusCode());
        self::assertSame(['error' => 'internal_error'], $this->json($response));
    }

    public function testGenericThrowableIsAlways500AndNeverLeaksInProd(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler(
            $request,
            new RuntimeException('database connection refused: pgsql://app@10.0.0.5:5432'),
            false,
            false,
            false,
        );

        self::assertSame(500, $response->getStatusCode());
        self::assertSame(['error' => 'internal_error'], $this->json($response));
    }

    public function testGenericThrowableWithCodeInClientRangeStillReturns500(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        // Some PDO/DBAL/parse exceptions carry a numeric code that *happens*
        // to fall in 400-499. The handler must not honour that as an HTTP
        // status — non-HttpException Throwables collapse to 500.
        $exception = new RuntimeException('PDO: 42 invalid SQL syntax', 422);

        $response = $handler($request, $exception, false, false, false);

        self::assertSame(500, $response->getStatusCode());
        self::assertSame(['error' => 'internal_error'], $this->json($response));
    }

    public function testHttpExceptionWithOutOfRangeCodeClampsTo500(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        // Bare `new HttpException(...)` with the default code=0 must not
        // emit an invalid HTTP status to the wire.
        $exception = new HttpException($request, 'oops', 0);

        $response = $handler($request, $exception, false, false, false);

        self::assertSame(500, $response->getStatusCode());
        self::assertSame(['error' => 'internal_error'], $this->json($response));
    }

    public function testHttpExceptionWithUnmappedKnownStatusFallsBackToInternalError(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        // 418 is in the valid range but not in the canonical token map.
        $exception = new HttpException($request, 'I am a teapot', 418);

        $response = $handler($request, $exception, false, false, false);

        self::assertSame(418, $response->getStatusCode());
        self::assertSame(['error' => 'internal_error'], $this->json($response));
    }

    public function testExposeDetailsAddsDetailFieldInDevelopment(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: true);

        $response = $handler(
            $request,
            new RuntimeException('database connection refused'),
            false,
            false,
            false,
        );

        self::assertSame(500, $response->getStatusCode());
        $body = $this->json($response);
        self::assertSame('internal_error', $body['error']);
        self::assertIsArray($body['detail']);
        self::assertSame(RuntimeException::class, $body['detail']['exception']);
        self::assertSame('database connection refused', $body['detail']['message']);
    }

    public function testDisplayErrorDetailsFlagAlsoExposesDetail(): void
    {
        $request = $this->request();
        // Constructor flag is false (production), but Slim's per-request
        // $displayErrorDetails should still gate exposure on for debug paths.
        $handler = $this->handler(expose: false);

        $response = $handler(
            $request,
            new HttpBadRequestException($request, 'bespoke validation failure'),
            true,
            false,
            false,
        );

        self::assertSame(400, $response->getStatusCode());
        $body = $this->json($response);
        self::assertSame('bad_request', $body['error']);
        self::assertSame('bespoke validation failure', $body['detail']['message']);
    }

    public function testEmptyMessageInDetailIsNullNotEmptyString(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: true);

        $response = $handler($request, new RuntimeException(''), false, false, false);

        $body = $this->json($response);
        self::assertNull($body['detail']['message']);
    }

    public function testResponseAlwaysHasJsonContentType(): void
    {
        $request = $this->request();
        $handler = $this->handler(expose: false);

        $response = $handler($request, new RuntimeException('x'), false, false, false);

        self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
    }

    private function handler(bool $expose): JsonErrorHandler
    {
        return new JsonErrorHandler(new ResponseFactory(), new NullLogger(), $expose);
    }

    private function request(): \Psr\Http\Message\ServerRequestInterface
    {
        return (new ServerRequestFactory())->createServerRequest('GET', 'https://api.test/x');
    }

    /**
     * @return array<string, mixed>
     */
    private function json(\Psr\Http\Message\ResponseInterface $response): array
    {
        $body = (string) $response->getBody();
        /** @var array<string, mixed> $decoded */
        $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

        return $decoded;
    }
}
