<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Middleware;

use App\Domain\Auth\AuthenticatedPrincipal;
use App\Domain\Auth\Token;
use App\Domain\Auth\TokenHasher;
use App\Domain\Auth\TokenKind;
use App\Infrastructure\Auth\TokenRepository;
use App\Infrastructure\Auth\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Validates the `Authorization: Bearer …` header, attaches an
 * AuthenticatedPrincipal to the request, and updates last_used_at.
 *
 * Returns a uniform 401 `{"error":"unauthorized"}` for any failure mode —
 * missing header, malformed token, unknown hash, revoked, expired,
 * issuer-disabled, issuer-demoted-below-token-role. The caller can never
 * tell which one. ImpersonationMiddleware and RbacMiddleware run after
 * this and supply 400/403 separately.
 *
 * SEC_REVIEW F16: admin tokens that carry a non-null `user_id` are
 * additionally validated against the issuer's current state — disabled or
 * demoted users do not authenticate via tokens they minted while still
 * privileged. Tokens with `user_id = null` (legacy admin rows from before
 * the binding migration, and admin tokens minted via `bin/console
 * tokens:create`) skip this check; they are grandfathered.
 */
final class TokenAuthenticationMiddleware implements MiddlewareInterface
{
    public const ATTR_PRINCIPAL = 'principal';
    public const ATTR_TOKEN_RECORD = 'tokenRecord';

    public function __construct(
        private readonly TokenRepository $tokens,
        private readonly TokenHasher $hasher,
        private readonly UserRepository $users,
        private readonly ResponseFactoryInterface $responseFactory,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $header = $request->getHeaderLine('Authorization');
        if ($header === '' || stripos($header, 'Bearer ') !== 0) {
            return $this->unauthorized();
        }

        $raw = trim(substr($header, 7));
        $parsed = Token::parse($raw);
        if ($parsed === null) {
            return $this->unauthorized();
        }

        $hash = $this->hasher->hash($raw);
        $record = $this->tokens->findByHash($hash);
        if ($record === null) {
            return $this->unauthorized();
        }

        // SEC_REVIEW F16: bound admin tokens follow their issuer's
        // privilege state. ON DELETE SET NULL on the FK means a token
        // with a non-null user_id always resolves to a row that existed
        // when last bound — but that row may now be disabled or demoted.
        // We refuse the token in either case, indistinguishable from any
        // other auth failure mode at the HTTP boundary.
        if ($record->kind === TokenKind::Admin && $record->userId !== null) {
            $issuer = $this->users->findById($record->userId);
            if ($issuer === null || $issuer->isDisabled()) {
                return $this->unauthorized();
            }
            if ($record->role !== null && !$issuer->role->satisfies($record->role)) {
                return $this->unauthorized();
            }
        }

        // Update last_used_at synchronously. Move to write-behind in M14
        // if perf demands.
        $this->tokens->markUsed((int) $record->id, new DateTimeImmutable('now', new DateTimeZone('UTC')));

        $principal = new AuthenticatedPrincipal(
            tokenKind: $record->kind,
            userId: null,
            role: $record->kind === TokenKind::Admin ? $record->role : null,
            reporterId: $record->reporterId,
            consumerId: $record->consumerId,
            tokenId: (int) $record->id,
        );

        $request = $request
            ->withAttribute(self::ATTR_TOKEN_RECORD, $record)
            ->withAttribute(self::ATTR_PRINCIPAL, $principal);

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

    private function unauthorized(): ResponseInterface
    {
        $response = $this->responseFactory->createResponse(401)
            ->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode(['error' => 'unauthorized']));

        return $response;
    }
}
