<?php

namespace apexl\Io\modules\user\Middleware;

use apexl\Config\ConfigurationInterface;
use apexl\Io\includes\RouteManager;
use apexl\Io\modules\display\Controllers\DisplayController;
use apexl\Io\modules\user\entities\userEntity;
use apexl\Io\modules\user\enums\permissions\Common;
use apexl\Io\modules\user\interfaces\PermissionInterface;
use apexl\Io\modules\user\services\AccessDeniedRedirect;
use apexl\Io\modules\user\services\CurrentUserFactory;
use apexl\Io\modules\user\services\PermissionDenialPayload;
use apexl\Io\modules\user\services\Permissions;
use apexl\Io\modules\user\services\RejectedCredential;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Routing\RouteContext;

final readonly class PermissionMiddleware implements MiddlewareInterface
{
    public function __construct(
        private StreamFactory $streamFactory,
        private RouteManager $routeManager,
        private CurrentUserFactory $currentUserFactory,
        private Permissions $permissions,
        private ConfigurationInterface $config,
    ) {}

    /**
     * Configured access-denied path, or null when unset / not resolvable — null
     * keeps every branch below byte-identical to the pre-config behaviour.
     */
    private function accessDeniedPath(): ?string
    {
        $target = AccessDeniedRedirect::configured($this->config);

        if ($target === null) {
            return null;
        }

        return AccessDeniedRedirect::isPath($target)
            ? $target
            : $this->routeManager->getRoutePattern($target);
    }

    /**
     * JSON body for a denied DATA route. Display routes keep the redirect
     * payload below; this is the branch that used to answer with nothing.
     */
    private function denialBody(mixed $permission, bool $isLoggedIn): string
    {
        $gate = ($permission instanceof PermissionInterface || is_array($permission)) ? $permission : null;

        return (string) json_encode(PermissionDenialPayload::build($gate, $isLoggedIn, $this->permissions));
    }

    /**
     * 401 for a rejected credential. Deliberately carries no `redirect` and no
     * `messages`: the client's job here is to refresh its token and retry, not
     * to navigate the user away or toast them. 403 stays reserved for a caller
     * we DID authenticate and then refused.
     */
    private function unauthenticated(): Response
    {
        return (new \Slim\Psr7\Response(401))
            ->withHeader('Content-Type', 'application/json')
            ->withHeader('WWW-Authenticate', sprintf('Bearer error="%s"', RejectedCredential::ERROR_INVALID_TOKEN))
            ->withBody($this->streamFactory->createStream((string) json_encode([
                'success' => false,
                'error'   => RejectedCredential::ERROR_INVALID_TOKEN,
            ])));
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
    {
        //always allow OPTIONS
        $method = $request->getMethod();
        if ($method == 'OPTIONS') {
            return $handler->handle($request);
        }

        $user = $this->currentUserFactory->get() ?? new userEntity();
        // retrieve the configured permission value
        $routeContext = RouteContext::fromRequest($request);
        $route = $routeContext->getRoute();
        $permission = $route->getArgument('permission');
        $permission = $permission ? unserialize($permission) : Common::ALLOW_ALL;

        //check if the user accessing this route has permission to do so.
        if (!$user->isAllowed($permission)) {
            // Authentication failure, not authorisation failure. A bearer token
            // that has expired (or was tampered with, or whose session has
            // ended) previously arrived here indistinguishable from an
            // anonymous caller and got the same 403 — which every client reads
            // as "signed in but not allowed", so none of them ever ran their
            // refresh flow. On a cold page reload past the access-token
            // lifetime that turned a recoverable session into a logout.
            // 401 is the signal that says "re-authenticate, then retry".
            if (RejectedCredential::wasPresented($request)) {
                return $this->unauthenticated();
            }

            $response = new \Slim\Psr7\Response(403);
            $callable = $route->getCallable();
            $deniedPath = $this->accessDeniedPath();

            // Opting in to an access-denied page also switches on strict
            // display-route detection — without it the redirect would never
            // reach "Fqcn::method" routes. Unset, both stay exactly as before.
            $isDisplayRoute = $deniedPath === null
                ? $this->shouldRedirectToLogin($callable)
                : AccessDeniedRedirect::isDisplayRoute($callable);

            if ($isDisplayRoute) {
                $isLoggedIn = (int) ($user->id ?? 0) > 0;

                return $response
                    ->withHeader('Content-Type', 'application/json')
                    ->withBody($this->streamFactory->createStream(json_encode([
                        // A signed-in user is denied, not unauthenticated; only
                        // anonymous callers are sent to the login page.
                        'redirect' => AccessDeniedRedirect::target(
                            $deniedPath,
                            $isLoggedIn,
                            $this->routeManager->getRoutePattern('user.display.login'),
                        ),
                    ])));
            }

            // Data route: same 403, but with the house message shape so the
            // client can tell the user what was refused instead of guessing.
            return $response
                ->withHeader('Content-Type', 'application/json')
                ->withBody($this->streamFactory->createStream(
                    $this->denialBody($permission, (int) ($user->id ?? 0) > 0),
                ));
        }

        return $handler->handle($request);
    }

    /** Legacy detector. Kept verbatim so the default (unconfigured) path is unchanged. */
    private function shouldRedirectToLogin(callable|array|string $callable): bool
    {
        return (is_string($callable) && is_subclass_of($callable, DisplayController::class))
            || (is_callable($callable) && $callable instanceof DisplayController);
    }
}
