<?php

namespace MintyPHP\Tests\Service\Auth;

use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\Auth\AuthSettingsGateway;
use MintyPHP\Service\Auth\RememberMeService;
use PHPUnit\Framework\TestCase;

class RememberMeServiceTest extends TestCase
{
    private array $previousSession = [];

    protected function setUp(): void
    {
        parent::setUp();
        $this->previousSession = $_SESSION ?? [];

        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }

        $_SESSION = [];
    }

    protected function tearDown(): void
    {
        $_SESSION = $this->previousSession;

        if (session_status() === PHP_SESSION_ACTIVE) {
            session_write_close();
        }

        parent::tearDown();
    }

    // ---------------------------------------------------------------
    // rememberUser — creates token and sets cookie
    // ---------------------------------------------------------------

    public function testRememberUserCreatesTokenAndSetsCookie(): void
    {
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->expects($this->once())
            ->method('create')
            ->with(
                42,
                $this->callback(fn (string $s): bool => strlen($s) === 24),
                $this->callback(fn (string $h): bool => strlen($h) === 64),
                $this->callback(fn (string $e): bool => strtotime($e . ' UTC') > time())
            )
            ->willReturn(1);

        $cookieStore = $this->createMock(CookieStoreInterface::class);
        $cookieStore->expects($this->once())
            ->method('set')
            ->with(
                'remember',
                $this->callback(fn (string $v): bool => str_contains($v, ':')),
                $this->callback(fn (array $opts): bool => ($opts['httponly'] ?? false) === true
                    && ($opts['samesite'] ?? '') === 'Lax')
            );

        $service = $this->newService(tokenRepository: $tokenRepo, cookieStore: $cookieStore);
        $service->rememberUser(42);
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — already logged in
    // ---------------------------------------------------------------

    public function testAutoLoginReturnsFalseWhenAlreadyLoggedIn(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], ['id' => 1]],
        ]);

        $service = $this->newService(sessionStore: $session);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — no cookie
    // ---------------------------------------------------------------

    public function testAutoLoginReturnsFalseWhenNoCookie(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('');

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — malformed cookie (no colon)
    // ---------------------------------------------------------------

    public function testAutoLoginReturnsFalseForMalformedCookieWithoutColon(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('no-colon-value');

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — empty selector or token after split
    // ---------------------------------------------------------------

    public function testAutoLoginClearsCookieWhenSelectorIsEmpty(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn(':sometoken');
        $cookie->expects($this->once())->method('remove');

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — selector not found in DB
    // ---------------------------------------------------------------

    public function testAutoLoginClearsCookieWhenSelectorNotFoundInDb(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');
        $cookie->expects($this->once())->method('remove');

        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn(null);

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — expired token
    // ---------------------------------------------------------------

    public function testAutoLoginDeletesTokenAndClearsCookieWhenExpired(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');
        $cookie->expects($this->once())->method('remove');

        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => hash('sha256', 'token'),
            'expires_at' => gmdate('Y-m-d H:i:s', time() - 3600),
        ]);
        $tokenRepo->expects($this->once())->method('deleteById')->with(10);

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — invalid token hash (tampered)
    // ---------------------------------------------------------------

    public function testAutoLoginDeletesTokenWhenHashMismatch(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:wrong-token');
        $cookie->expects($this->once())->method('remove');

        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => hash('sha256', 'correct-token'),
            'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
        ]);
        $tokenRepo->expects($this->once())->method('deleteById')->with(10);

        $service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — user not found or inactive
    // ---------------------------------------------------------------

    public function testAutoLoginDeletesTokenWhenUserNotFound(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');
        $cookie->expects($this->once())->method('remove');

        $tokenHash = hash('sha256', 'token');
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => $tokenHash,
            'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
        ]);
        $tokenRepo->expects($this->once())->method('deleteById')->with(10);

        $userRead = $this->createMock(UserReadRepositoryInterface::class);
        $userRead->method('find')->willReturn(null);

        $service = $this->newService(
            sessionStore: $session,
            cookieStore: $cookie,
            tokenRepository: $tokenRepo,
            userReadRepository: $userRead
        );
        $this->assertFalse($service->autoLoginFromCookie());
    }

    public function testAutoLoginDeletesTokenWhenUserInactive(): void
    {
        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnMap([
            ['user', [], []],
        ]);

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');
        $cookie->expects($this->once())->method('remove');

        $tokenHash = hash('sha256', 'token');
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => $tokenHash,
            'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
        ]);
        $tokenRepo->expects($this->once())->method('deleteById')->with(10);

        $userRead = $this->createMock(UserReadRepositoryInterface::class);
        $userRead->method('find')->willReturn(['id' => 5, 'active' => 0]);

        $service = $this->newService(
            sessionStore: $session,
            cookieStore: $cookie,
            tokenRepository: $tokenRepo,
            userReadRepository: $userRead
        );
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — success with token rotation
    // ---------------------------------------------------------------

    public function testAutoLoginSucceedsAndRotatesToken(): void
    {
        $_SESSION = [];

        $session = $this->createMock(SessionStoreInterface::class);
        $setKeys = [];
        $session->method('get')->willReturnMap([
            ['user', [], []],
            ['no_active_tenant', false, false],
        ]);
        $session->method('set')->willReturnCallback(function (string $key, mixed $value) use (&$setKeys): void {
            $setKeys[] = $key;
        });

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');
        $cookie->expects($this->once())->method('set')
            ->with('remember', $this->callback(fn (string $v): bool => str_starts_with($v, 'selector:')), $this->anything());

        $tokenHash = hash('sha256', 'token');
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => $tokenHash,
            'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
        ]);
        $tokenRepo->expects($this->once())->method('updateToken')
            ->with(10, $this->callback(fn (string $h): bool => strlen($h) === 64), $this->anything());
        $tokenRepo->expects($this->never())->method('deleteById');

        $userRead = $this->createMock(UserReadRepositoryInterface::class);
        $userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);

        $permService = $this->createMock(PermissionService::class);
        $permService->expects($this->once())->method('getUserPermissions')->with(5, true);

        $tenantCtx = $this->createMock(AuthSessionTenantContextService::class);
        $tenantCtx->expects($this->once())->method('hydrateForUser')->with(5);

        $userWrite = $this->createMock(UserWriteRepositoryInterface::class);
        $userWrite->expects($this->once())->method('updateLastLogin')->with(5, 'local');

        $service = $this->newService(
            sessionStore: $session,
            cookieStore: $cookie,
            tokenRepository: $tokenRepo,
            userReadRepository: $userRead,
            userWriteRepository: $userWrite,
            permissionService: $permService,
            authSessionTenantContextService: $tenantCtx
        );
        $this->assertTrue($service->autoLoginFromCookie());
        $this->assertContains('user', $setKeys);
        $this->assertContains('session_started_at', $setKeys);
        $this->assertContains('session_last_activity', $setKeys);
    }

    // ---------------------------------------------------------------
    // autoLoginFromCookie — no active tenant triggers logout
    // ---------------------------------------------------------------

    public function testAutoLoginLogsOutWhenNoActiveTenant(): void
    {
        $_SESSION = [];

        $session = $this->createMock(SessionStoreInterface::class);
        $session->method('get')->willReturnCallback(function (string $key, mixed $default) {
            if ($key === 'user') {
                return [];
            }
            if ($key === 'no_active_tenant') {
                return true;
            }
            return $default;
        });

        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('selector:token');

        $tokenHash = hash('sha256', 'token');
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn([
            'id' => 10,
            'user_id' => 5,
            'token_hash' => $tokenHash,
            'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
        ]);

        $userRead = $this->createMock(UserReadRepositoryInterface::class);
        $userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);

        $service = $this->newService(
            sessionStore: $session,
            cookieStore: $cookie,
            tokenRepository: $tokenRepo,
            userReadRepository: $userRead
        );
        $this->assertFalse($service->autoLoginFromCookie());
    }

    // ---------------------------------------------------------------
    // forgetCurrentUser
    // ---------------------------------------------------------------

    public function testForgetCurrentUserDeletesTokenAndClearsCookie(): void
    {
        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('sel:tok');
        $cookie->expects($this->once())->method('remove');

        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->method('findBySelector')->willReturn(['id' => 7]);
        $tokenRepo->expects($this->once())->method('deleteById')->with(7);

        $service = $this->newService(cookieStore: $cookie, tokenRepository: $tokenRepo);
        $service->forgetCurrentUser();
    }

    public function testForgetCurrentUserDoesNothingWhenNoCookie(): void
    {
        $cookie = $this->createMock(CookieStoreInterface::class);
        $cookie->method('get')->willReturn('');
        $cookie->expects($this->once())->method('remove');

        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->expects($this->never())->method('findBySelector');
        $tokenRepo->expects($this->never())->method('deleteById');

        $service = $this->newService(cookieStore: $cookie, tokenRepository: $tokenRepo);
        $service->forgetCurrentUser();
    }

    // ---------------------------------------------------------------
    // forgetAllForUser
    // ---------------------------------------------------------------

    // ---------------------------------------------------------------
    // Dynamic TTL via AuthSettingsGateway
    // ---------------------------------------------------------------

    public function testRememberUserUsesConfiguredTtlFromGateway(): void
    {
        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getRememberTokenLifetimeDays')->willReturn(7);

        $capturedExpiresAt = '';
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->expects($this->once())
            ->method('create')
            ->with(
                42,
                $this->anything(),
                $this->anything(),
                $this->callback(function (string $e) use (&$capturedExpiresAt): bool {
                    $capturedExpiresAt = $e;
                    return strtotime($e . ' UTC') > time();
                })
            )
            ->willReturn(1);

        $cookieStore = $this->createMock(CookieStoreInterface::class);
        $cookieStore->expects($this->once())
            ->method('set')
            ->with(
                'remember',
                $this->anything(),
                $this->callback(fn (array $opts): bool => $opts['expires'] > time()
                    && $opts['expires'] <= time() + (7 * 86400) + 10)
            );

        $service = $this->newService(
            tokenRepository: $tokenRepo,
            cookieStore: $cookieStore,
            authSettingsGateway: $settingsGateway
        );
        $service->rememberUser(42);

        // Verify expiration is ~7 days from now (not 30)
        $expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
        $this->assertGreaterThan(time() + (6 * 86400), $expiresTimestamp);
        $this->assertLessThanOrEqual(time() + (7 * 86400) + 10, $expiresTimestamp);
    }

    public function testRememberUserFallsBackTo30DaysWhenNoGateway(): void
    {
        $capturedExpiresAt = '';
        $tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
        $tokenRepo->expects($this->once())
            ->method('create')
            ->with(
                42,
                $this->anything(),
                $this->anything(),
                $this->callback(function (string $e) use (&$capturedExpiresAt): bool {
                    $capturedExpiresAt = $e;
                    return true;
                })
            )
            ->willReturn(1);

        $cookieStore = $this->createMock(CookieStoreInterface::class);
        $cookieStore->method('set');

        $service = $this->newService(tokenRepository: $tokenRepo, cookieStore: $cookieStore);
        $service->rememberUser(42);

        $expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
        $this->assertGreaterThan(time() + (29 * 86400), $expiresTimestamp);
        $this->assertLessThanOrEqual(time() + (30 * 86400) + 10, $expiresTimestamp);
    }

    // ---------------------------------------------------------------
    // Helper
    // ---------------------------------------------------------------

    private function newService(
        ?UserReadRepositoryInterface $userReadRepository = null,
        ?UserWriteRepositoryInterface $userWriteRepository = null,
        ?RememberTokenRepositoryInterface $tokenRepository = null,
        ?PermissionService $permissionService = null,
        ?AuthSessionTenantContextService $authSessionTenantContextService = null,
        ?SessionStoreInterface $sessionStore = null,
        ?CookieStoreInterface $cookieStore = null,
        ?RequestRuntimeInterface $requestRuntime = null,
        ?AuthSettingsGateway $authSettingsGateway = null
    ): RememberMeService {
        // RequestRuntimeInterface has a method named "method()" which PHPUnit 12
        // cannot mock (reserved name). Use a simple anonymous-class stub instead.
        $requestRuntime ??= new class () implements RequestRuntimeInterface {
            public function method(): string
            {
                return 'GET';
            }

            public function path(): string
            {
                return '/';
            }

            public function ip(): string
            {
                return '127.0.0.1';
            }

            public function userAgent(): string
            {
                return 'test';
            }

            public function queryParams(): array
            {
                return [];
            }

            public function isSecure(): bool
            {
                return false;
            }
        };

        return new RememberMeService(
            $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class),
            $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class),
            $tokenRepository ?? $this->createMock(RememberTokenRepositoryInterface::class),
            $permissionService ?? $this->createMock(PermissionService::class),
            $authSessionTenantContextService ?? $this->createMock(AuthSessionTenantContextService::class),
            $sessionStore ?? $this->createMock(SessionStoreInterface::class),
            $cookieStore ?? $this->createMock(CookieStoreInterface::class),
            $requestRuntime,
            $authSettingsGateway
        );
    }
}
