<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Auth;

use App\Auth\SessionGuard;
use App\Tests\TestCase;

/**
 * Pure-static unit tests for the R01-N08 idle-timeout helpers on
 * SessionGuard. These cover the boundary semantics of `isIdleExpired`
 * and the in-place mutation contract of `expireIdleSession` on a
 * vanilla PHP array, so the tests don't depend on the global
 * `$_SESSION`, cookies, or `session_start()` state.
 *
 * The CSRF rotation behaviour of `login()` is asserted indirectly
 * through `expireIdleSession`'s "drop csrf_token on idle" assertion —
 * the same `unset()` semantics are reused by the login() body to
 * force a fresh token on the next `csrfToken()` call.
 */
final class SessionGuardTest extends TestCase
{
    public function testIdleTimeoutConstantIsThirtyMinutes(): void
    {
        // Locks the policy so an accidental change shows up here as well as
        // in the boundary tests below.
        self::assertSame(1800, SessionGuard::IDLE_TIMEOUT_SECONDS);
    }

    public function testIsIdleExpiredFreshAndStaleBoundary(): void
    {
        $base = 1_700_000_000;

        self::assertFalse(
            SessionGuard::isIdleExpired($base, $base),
            'zero gap is fresh',
        );
        self::assertFalse(
            SessionGuard::isIdleExpired($base, $base + 1),
            '1s gap is fresh',
        );
        self::assertFalse(
            SessionGuard::isIdleExpired($base, $base + 1799),
            '29:59 is still fresh',
        );
        self::assertTrue(
            SessionGuard::isIdleExpired($base, $base + 1800),
            'exactly the threshold is expired (>= boundary)',
        );
        self::assertTrue(
            SessionGuard::isIdleExpired($base, $base + 7200),
            '2h gap is expired',
        );
    }

    public function testExpireIdleSessionLeavesAnonymousSessionAlone(): void
    {
        $now    = 1_700_000_000;
        $before = ['oidc_state' => 'abc123', 'oidc_nonce' => 'xyz789'];

        $session = $before;
        $rotate  = SessionGuard::expireIdleSession($session, $now);

        self::assertFalse($rotate);
        self::assertSame(
            $before,
            $session,
            'anonymous session: helper must not bump last_active or rotate',
        );
    }

    public function testExpireIdleSessionBumpsLastActiveOnFreshHit(): void
    {
        $now     = 1_700_000_000;
        $session = [
            'user_id'     => 42,
            'login_at'    => $now - 600,
            'last_active' => $now - 600,
            'csrf_token'  => 'deadbeef',
        ];

        $rotate = SessionGuard::expireIdleSession($session, $now);

        self::assertFalse($rotate, '10-min idle is well within the window');
        self::assertSame(42, $session['user_id']);
        self::assertSame($now, $session['last_active'], 'last_active slides forward');
        self::assertSame('deadbeef', $session['csrf_token'], 'csrf_token preserved');
    }

    public function testExpireIdleSessionBumpsLastActiveWhenMissing(): void
    {
        // A session that was created before the R01-N08 patch shipped will
        // have user_id but no last_active key. The helper must seed it
        // (not clobber the user with a phantom expiry).
        $now     = 1_700_000_000;
        $session = ['user_id' => 7, 'login_at' => $now - 60];

        $rotate = SessionGuard::expireIdleSession($session, $now);

        self::assertFalse($rotate);
        self::assertSame($now, $session['last_active']);
        self::assertSame(7, $session['user_id']);
    }

    public function testExpireIdleSessionDropsAuthKeysWhenIdle(): void
    {
        $now     = 1_700_000_000;
        $session = [
            'user_id'     => 42,
            'login_at'    => $now - 7200,
            'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
            'csrf_token'  => 'deadbeef',
            // Foreign keys must survive — the OIDC library can store
            // pre-login state under arbitrary keys.
            'oidc_state'  => 'abc123',
        ];

        $rotate = SessionGuard::expireIdleSession($session, $now);

        self::assertTrue($rotate, 'caller must rotate the session id');
        self::assertArrayNotHasKey('user_id', $session);
        self::assertArrayNotHasKey('login_at', $session);
        self::assertArrayNotHasKey('last_active', $session);
        self::assertArrayNotHasKey('csrf_token', $session);
        self::assertSame(
            'abc123',
            $session['oidc_state'] ?? null,
            'foreign session state must survive the timeout',
        );
    }

    public function testExpireIdleSessionExactlyAtBoundaryExpires(): void
    {
        // The boundary is `>=` so a session whose last_active is exactly
        // IDLE_TIMEOUT_SECONDS ago is treated as expired.
        $now     = 1_700_000_000;
        $session = [
            'user_id'     => 42,
            'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
            'csrf_token'  => 'deadbeef',
        ];

        self::assertTrue(SessionGuard::expireIdleSession($session, $now));
        self::assertArrayNotHasKey('user_id', $session);
    }

    public function testExpireIdleSessionOneSecondBeforeBoundaryIsFresh(): void
    {
        $now     = 1_700_000_000;
        $session = [
            'user_id'     => 42,
            'last_active' => $now - (SessionGuard::IDLE_TIMEOUT_SECONDS - 1),
            'csrf_token'  => 'deadbeef',
        ];

        self::assertFalse(SessionGuard::expireIdleSession($session, $now));
        self::assertSame(42, $session['user_id']);
        self::assertSame($now, $session['last_active']);
    }

    public function testExpireIdleSessionIgnoresNonIntUserId(): void
    {
        // `currentUserId()` defends with `is_int($id) ? $id : null`, so a
        // bogus string user_id is effectively anonymous. Mirror that
        // contract here — no expiry, no last_active bump.
        $now     = 1_700_000_000;
        $session = ['user_id' => 'not-an-int', 'last_active' => $now - 9999];

        $rotate = SessionGuard::expireIdleSession($session, $now);

        self::assertFalse($rotate);
        self::assertSame('not-an-int', $session['user_id']);
        self::assertSame($now - 9999, $session['last_active']);
    }
}
