<?php

declare(strict_types=1);

namespace apexl\Io\middleware;

use apexl\Config\ConfigurationInterface;
use apexl\Config\Exceptions\MissingConfigException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\StreamFactory;
use Throwable;

final readonly class AddCorsHeaders implements MiddlewareInterface
{
    public function __construct(
        private ConfigurationInterface $config,
        private ResponseFactory $responseFactory,
        private StreamFactory $streamFactory,
    ) {}

    /**
     * @throws MissingConfigException
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // OPTIONS preflight is short-circuited upstream by Client::shortCircuitOptionsRequest
        // before we reach modules/routing — that handler still runs us via $app->add(self),
        // so all CORS-header decisions still happen here regardless of method.
        try {
            $response = $handler->handle($request);
        } catch (Throwable $e) {
            // No inner middleware (e.g. Whoops) caught this. Build a JSON error
            // response so we have something to attach CORS headers to — otherwise
            // the browser blocks the FE from reading the error body and debugging
            // dies. CORS headers still subject to the same domain check as a
            // successful response.
            $response = $this->buildErrorResponse($e);
        }

        return $this->maybeAddCorsHeaders($request, $response);
    }

    /**
     * @throws MissingConfigException
     */
    private function buildErrorResponse(Throwable $e): ResponseInterface
    {
        $body = ['error' => $e->getMessage()];

        if (in_array($this->config->get('app.environment'), ['local', 'development'], true)) {
            $body['exception'] = $e::class;
            $body['file'] = $e->getFile() . ':' . $e->getLine();
            $body['trace'] = explode("\n", $e->getTraceAsString());
        }

        $stream = $this->streamFactory->createStream(
            (string) json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
        );

        return $this->responseFactory->createResponse(500)
            ->withHeader('Content-Type', 'application/json')
            ->withBody($stream);
    }


    /**
     * @throws MissingConfigException
     */
    private function maybeAddCorsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $origin = $this->originFromRequest($request);
        if ($origin === null) {
            return $response;
        }

        $allowed = $this->matchOriginToAllowedDomains($origin);
        if ($allowed === null) {
            return $response;
        }

        return $this->addCorsHeaders($response, $allowed);
    }

    /**
     * Prefer the Origin header (set by browsers on all CORS requests/preflights);
     * fall back to Referer with a parse_url to derive the origin from the URL path.
     */
    private function originFromRequest(RequestInterface $request): ?string
    {
        $origin = $request->getHeaderLine('Origin');
        if ($origin !== '') {
            return $origin;
        }

        $referer = $request->getHeaderLine('Referer');
        if ($referer === '') {
            return null;
        }

        $bits = parse_url($referer);
        if (!isset($bits['scheme'], $bits['host'])) {
            return null;
        }

        return sprintf(
            '%s://%s%s',
            $bits['scheme'],
            $bits['host'],
            isset($bits['port']) ? sprintf(':%s', $bits['port']) : '',
        );
    }

    /**
     * @throws MissingConfigException
     */
    public function addCorsHeaders(ResponseInterface $response, string $origin = '*'): ResponseInterface
    {
        $response = $response
            ->withHeader('Access-Control-Allow-Origin', $origin)
            ->withHeader(
                'Access-Control-Allow-Headers',
                implode(', ', [
                    'X-Requested-With',
                    'Content-Type',
                    'Accept',
                    'Origin',
                    'Authorization',
                    'Cache-Control',
                    'Pragma',
                    ...$this->config->get('http.allowed_headers'),
                ])
            )
            ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
            ->withHeader('Access-Control-Allow-Credentials', $origin === '*' ? 'false' : 'true');

        // Tell caches the response varies by Origin so a different-origin request
        // doesn't get served a stale CORS header from a previous origin.
        if ($origin !== '*') {
            $response = $response->withHeader('Vary', 'Origin');
        }

        return $response;
    }

    /**
     * @throws MissingConfigException
     */
    private function matchOriginToAllowedDomains(string $origin): ?string
    {
        $allowedDomains = $this->config->get('http.allowed_domains');
        $normalised = rtrim($origin, '/');

        foreach ($allowedDomains as $allowed) {
            if (rtrim((string) $allowed, '/') === $normalised) {
                return $normalised;
            }
        }

        return null;
    }

}
