<?php

declare(strict_types=1);

namespace App\Tests\Integration\Auth;

use App\Auth\UserContext;
use App\Tests\Integration\Support\AppTestCase;

/**
 * SEC_REVIEW F36 — `AuthRequiredMiddleware` periodically revalidates the
 * cached user against `GET /api/v1/admin/me`. A role change in Entra,
 * an explicit disable in the api, or a deleted user record propagates
 * to the live UI session at the next protected request that's past the
 * revalidation window — no need to wait for the 8 h idle timeout.
 */
final class SessionRevalidationTest extends AppTestCase
{
    protected function setUp(): void
    {
        $this->bootApp();
        // The first /app/* request in a process triggers session_start(),
        // which clobbers $_SESSION values primed by the test. Hit a public
        // route first so the session is already active before the test
        // request fires (same workaround as SearchPageTest).
        $this->request('GET', '/healthz');
    }

    public function testWithinIntervalSkipsRevalidation(): void
    {
        // Prime a fresh session with `_revalidated_at = now`. The middleware
        // should NOT call the api on this request.
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time());
        // Dashboard fires its own getDashboardStats() call; stub it.
        $this->stubDashboardApiCalls();

        $response = $this->request('GET', '/app/dashboard');

        // We reached the dashboard handler — middleware did not bounce us
        // back to /login or trigger a revalidation request to /admin/me.
        self::assertNotSame('/login', $response->getHeaderLine('Location'));
        // Exactly one api call: the dashboard stats. No /admin/me call.
        $apiPaths = array_map(
            static fn (array $entry): string => (string) $entry['request']->getUri()->getPath(),
            $this->apiHistory,
        );
        self::assertNotContains('/api/v1/admin/me', $apiPaths);
    }

    public function testRevalidationKeepsSessionWhenRoleUnchanged(): void
    {
        // `_revalidated_at` set to 10 minutes ago → revalidation triggers.
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
        $this->enqueueApiResponse(200, [
            'user_id' => 1, 'role' => 'admin', 'email' => 'a@x',
            'display_name' => 'Admin', 'is_local' => true,
        ]);
        // Dashboard page itself fires its own api calls — feed a permissive stub.
        $this->stubDashboardApiCalls();

        $response = $this->request('GET', '/app/dashboard');

        self::assertNotSame(302, $response->getStatusCode());
        self::assertSame('admin', $_SESSION['_user']['role']);
        // `_revalidated_at` was rotated forward.
        self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
    }

    public function testRevalidationUpdatesSessionWhenRoleChanged(): void
    {
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
        // Api now reports the user as a viewer (was admin in session).
        $this->enqueueApiResponse(200, [
            'user_id' => 1, 'role' => 'viewer', 'email' => 'a@x',
            'display_name' => 'Admin', 'is_local' => true,
        ]);
        $this->stubDashboardApiCalls();

        $this->request('GET', '/app/dashboard');

        self::assertSame('viewer', $_SESSION['_user']['role']);
    }

    public function testDisabledUserIsKickedToLogin(): void
    {
        // User disabled in api → ImpersonationMiddleware returns 403 user_disabled.
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
        $this->enqueueApiResponse(403, ['error' => 'user_disabled']);

        $response = $this->request('GET', '/app/dashboard');

        self::assertSame(302, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        self::assertArrayNotHasKey('_user', $_SESSION);
        // The clear-and-flash-and-redirect is what the user sees.
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertSame('error', $flash[0]['type']);
        self::assertStringContainsString('revoked', $flash[0]['message']);
    }

    public function testDeletedUserIsKickedToLogin(): void
    {
        // Api reports 403 unknown impersonated user.
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
        $this->enqueueApiResponse(403, ['error' => 'unknown impersonated user']);

        $response = $this->request('GET', '/app/dashboard');

        self::assertSame(302, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        self::assertArrayNotHasKey('_user', $_SESSION);
    }

    public function testApiUnreachableKeepsSessionAlive(): void
    {
        // API down: an outage must NOT lock every session out. The middleware
        // should log + tolerate, marking the session revalidated so it doesn't
        // hammer the api on every request.
        $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
        // Two ConnectExceptions because ApiClient retries once on connect.
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'down',
            new \GuzzleHttp\Psr7\Request('GET', '/'),
        ));
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'down',
            new \GuzzleHttp\Psr7\Request('GET', '/'),
        ));
        $this->stubDashboardApiCalls();

        $response = $this->request('GET', '/app/dashboard');

        // Session preserved.
        self::assertNotSame('/login', $response->getHeaderLine('Location'));
        self::assertSame('admin', $_SESSION['_user']['role']);
        // Marked revalidated even though the api was down.
        self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
    }

    public function testLegacySessionWithoutRevalidatedAtIsBootstrapped(): void
    {
        // Pre-F36 sessions don't have `_revalidated_at`. The middleware
        // must bootstrap the timestamp instead of revalidating immediately
        // (no api call, no test breakage on legacy fixtures).
        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
        $_SESSION['_last_active'] = time();
        $_SESSION['_authenticated_at'] = time();
        // No _revalidated_at deliberately.
        $this->stubDashboardApiCalls();

        $this->request('GET', '/app/dashboard');

        self::assertArrayHasKey('_revalidated_at', $_SESSION);
        self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
    }

    private function seedSession(int $userId, string $role, int $revalidatedAt): void
    {
        $_SESSION['_user'] = (new UserContext(
            $userId,
            'Admin',
            $role,
            'a@x',
            UserContext::SOURCE_LOCAL,
        ))->toArray();
        $_SESSION['_last_active'] = time();
        $_SESSION['_authenticated_at'] = time();
        $_SESSION['_revalidated_at'] = $revalidatedAt;
    }

    /**
     * Dashboard page fires its own api calls. Feed empty/200 stubs so the
     * test focuses on the revalidation outcome rather than the page content.
     */
    private function stubDashboardApiCalls(): void
    {
        // Dashboard issues a getDashboardStats() call; queue a permissive
        // empty payload. Tests that 302 away never reach the dashboard so
        // unconsumed stubs don't cause failures.
        $this->enqueueApiResponse(200, [
            'totals' => [],
            'top_categories' => [],
            'top_countries' => [],
            'last_7_days' => [],
            'top_reporters_30d' => [],
        ]);
    }
}
