<?php

declare(strict_types=1);

namespace App\Infrastructure\Http;

use App\Infrastructure\Logging\SafeTrace;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\Exception\HttpException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Throwable;

/**
 * Slim-compatible error handler that produces uniform
 * `{"error":"<token>"}` JSON responses.
 *
 * Production responses never echo `Throwable::getMessage()`. Status codes
 * are mapped to a fixed set of snake_case tokens; any unmapped or
 * out-of-range code collapses to 500 / `internal_error`. In development
 * (`exposeDetails=true` or Slim's `$displayErrorDetails` flag), the
 * exception class and message are added under `detail` for debuggability.
 *
 * Wired in public/index.php as the default invokable.
 */
final class JsonErrorHandler
{
    /** @var array<int, string> */
    private const STATUS_TOKENS = [
        400 => 'bad_request',
        401 => 'unauthorized',
        403 => 'forbidden',
        404 => 'not_found',
        405 => 'method_not_allowed',
        406 => 'not_acceptable',
        409 => 'conflict',
        410 => 'gone',
        411 => 'length_required',
        412 => 'precondition_failed',
        413 => 'payload_too_large',
        414 => 'uri_too_long',
        415 => 'unsupported_media_type',
        422 => 'unprocessable_entity',
        429 => 'too_many_requests',
        500 => 'internal_error',
        501 => 'not_implemented',
        502 => 'bad_gateway',
        503 => 'service_unavailable',
        504 => 'gateway_timeout',
    ];

    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly LoggerInterface $logger,
        private readonly bool $exposeDetails,
    ) {
    }

    public function __invoke(
        ServerRequestInterface $request,
        Throwable $exception,
        bool $displayErrorDetails,
        bool $logErrors,
        bool $logErrorDetails,
    ): ResponseInterface {
        if ($logErrors) {
            $this->logger->error($exception->getMessage(), [
                'exception' => $exception::class,
                'method' => $request->getMethod(),
                'path' => $request->getUri()->getPath(),
                'trace' => $logErrorDetails ? SafeTrace::format($exception) : null,
            ]);
        }

        $expose = $displayErrorDetails || $this->exposeDetails;
        [$status, $payload] = $this->shape($exception, $expose);

        $response = $this->responseFactory->createResponse($status)
            ->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode($payload));

        return $response;
    }

    /**
     * @return array{int, array<string, mixed>}
     */
    private function shape(Throwable $e, bool $expose): array
    {
        if ($e instanceof HttpNotFoundException) {
            return [404, $this->payload(404, $e, $expose)];
        }
        if ($e instanceof HttpMethodNotAllowedException) {
            return [405, $this->payload(405, $e, $expose)];
        }
        if ($e instanceof HttpException) {
            $status = $this->safeStatus($e->getCode());

            return [$status, $this->payload($status, $e, $expose)];
        }

        return [500, $this->payload(500, $e, $expose)];
    }

    /**
     * @return array<string, mixed>
     */
    private function payload(int $status, Throwable $e, bool $expose): array
    {
        $payload = ['error' => self::STATUS_TOKENS[$status] ?? 'internal_error'];
        if ($expose) {
            $message = $e->getMessage();
            $payload['detail'] = [
                'exception' => $e::class,
                'message' => $message !== '' ? $message : null,
            ];
        }

        return $payload;
    }

    private function safeStatus(int $code): int
    {
        if ($code < 400 || $code > 599) {
            return 500;
        }

        return $code;
    }
}
