<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Tests\Unit;

use OCA\Mitgliederverwaltung\Middleware\RateLimitMiddleware;
use OCP\AppFramework\Http;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

class RateLimitMiddlewareTest extends TestCase {

    private IRequest&MockObject $request;
    private IUserSession&MockObject $userSession;
    private ICacheFactory&MockObject $cacheFactory;
    private ICache&MockObject $cache;
    private LoggerInterface&MockObject $logger;
    private IUser&MockObject $user;

    protected function setUp(): void {
        parent::setUp();

        $this->request = $this->createMock(IRequest::class);
        $this->userSession = $this->createMock(IUserSession::class);
        $this->cacheFactory = $this->createMock(ICacheFactory::class);
        $this->cache = $this->createMock(ICache::class);
        $this->logger = $this->createMock(LoggerInterface::class);
        $this->user = $this->createMock(IUser::class);
        $this->user->method('getUID')->willReturn('testuser');
    }

    private function createMiddleware(bool $cacheAvailable = true): RateLimitMiddleware {
        $this->cacheFactory->method('isAvailable')->willReturn($cacheAvailable);
        if ($cacheAvailable) {
            $this->cacheFactory->method('createDistributed')
                ->with('mv_ratelimit')
                ->willReturn($this->cache);
        }
        return new RateLimitMiddleware(
            $this->request,
            $this->userSession,
            $this->cacheFactory,
            $this->logger
        );
    }

    // -- Allows requests under limit --

    public function testAllowsFirstRequestUnderLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/members');

        $this->cache->method('get')->willReturn(null);
        $this->cache->expects($this->once())
            ->method('set')
            ->with(
                $this->anything(),
                1,
                60
            );

        // Should not throw
        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testAllowsSubsequentRequestsUnderLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/members');

        $this->cache->method('get')->willReturn(5);
        $this->cache->expects($this->once())
            ->method('set')
            ->with(
                $this->anything(),
                6,
                60
            );

        // Should not throw - 5 < 120 (default limit)
        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testAllowsSearchRequestsUnderLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/search');

        $this->cache->method('get')->willReturn(30);
        $this->cache->expects($this->once())
            ->method('set')
            ->with(
                $this->anything(),
                31,
                60
            );

        // Should not throw - 30 < 60 (search limit)
        $middleware->beforeController(new \stdClass(), 'search');
    }

    // -- Blocks requests over limit --

    public function testBlocksRequestsOverDefaultLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/members');

        $this->cache->method('get')->willReturn(120);

        $this->logger->expects($this->once())->method('warning');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Rate limit exceeded');

        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testBlocksSearchRequestsOverLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/search');

        $this->cache->method('get')->willReturn(60);

        $this->logger->expects($this->once())->method('warning');

        $this->expectException(\RuntimeException::class);
        $middleware->beforeController(new \stdClass(), 'search');
    }

    public function testBlocksExportRequestsOverLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/export/csv');

        $this->cache->method('get')->willReturn(10);

        $this->expectException(\RuntimeException::class);
        $middleware->beforeController(new \stdClass(), 'export');
    }

    public function testBlocksReportRequestsOverLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/report/fees');

        $this->cache->method('get')->willReturn(10);

        $this->expectException(\RuntimeException::class);
        $middleware->beforeController(new \stdClass(), 'report');
    }

    public function testBlocksImportRequestsOverLimit(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/import/csv');

        $this->cache->method('get')->willReturn(5);

        $this->expectException(\RuntimeException::class);
        $middleware->beforeController(new \stdClass(), 'import');
    }

    // -- Rate limit resets after window / cache null behavior --

    public function testNoCacheAvailableAllowsAll(): void {
        $middleware = $this->createMiddleware(false);
        $this->userSession->method('getUser')->willReturn($this->user);

        // Should never even check cache
        $this->cache->expects($this->never())->method('get');

        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testNoUserSessionAllowsAll(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn(null);

        $this->cache->expects($this->never())->method('get');

        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testRateLimitResetsAfterWindow(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);
        $this->request->method('getRequestUri')->willReturn('/apps/mitgliederverwaltung/api/members');

        // null return simulates the cache entry having expired (window passed)
        $this->cache->method('get')->willReturn(null);
        $this->cache->expects($this->once())
            ->method('set')
            ->with(
                $this->anything(),
                1, // resets to 1
                60
            );

        $middleware->beforeController(new \stdClass(), 'index');
    }

    // -- Query parameter normalization --

    public function testSameEndpointWithDifferentQueryParamsSharesBucket(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);

        // Two requests to the same path but with different query parameters
        // should share the same rate limit bucket
        $this->request->method('getRequestUri')
            ->willReturn('/apps/mitgliederverwaltung/api/members?_=12345');

        $this->cache->method('get')->willReturn(null);
        $this->cache->expects($this->once())
            ->method('set')
            ->with(
                // The cache key should be based on path without query params
                $this->equalTo('rl_testuser_' . md5('/apps/mitgliederverwaltung/api/members')),
                1,
                60
            );

        $middleware->beforeController(new \stdClass(), 'index');
    }

    public function testQueryParamBypassBlocked(): void {
        $middleware = $this->createMiddleware();
        $this->userSession->method('getUser')->willReturn($this->user);

        // Request with random query param should still be rate limited
        $this->request->method('getRequestUri')
            ->willReturn('/apps/mitgliederverwaltung/api/export/csv?_=99999');

        // At limit for export (10)
        $this->cache->method('get')->willReturn(10);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Rate limit exceeded');

        $middleware->beforeController(new \stdClass(), 'export');
    }

    // -- afterException tests --

    public function testAfterExceptionReturns429ForRateLimitExceeded(): void {
        $middleware = $this->createMiddleware();

        $exception = new \RuntimeException('Rate limit exceeded');
        $response = $middleware->afterException(new \stdClass(), 'index', $exception);

        $this->assertSame(Http::STATUS_TOO_MANY_REQUESTS, $response->getStatus());
        $data = $response->getData();
        $this->assertArrayHasKey('error', $data);
        $this->assertStringContainsString('Zu viele Anfragen', $data['error']);
    }

    public function testAfterExceptionRethrowsOtherExceptions(): void {
        $middleware = $this->createMiddleware();

        $exception = new \RuntimeException('Some other error');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Some other error');

        $middleware->afterException(new \stdClass(), 'index', $exception);
    }
}
