<?php

declare(strict_types=1);

namespace App\Tests\Integration\Auth;

use App\Auth\OidcAuthenticator;
use App\Auth\OidcClaims;
use App\Auth\OidcException;
use App\Tests\Integration\Support\AppTestCase;

/**
 * OIDC flow tested with a stub `OidcAuthenticator`. Real Entra
 * authentication is verified manually; this test guards against
 * regressions in the controller's success/no-access/error branches.
 */
final class OidcFlowTest extends AppTestCase
{
    protected function setUp(): void
    {
        $this->bootApp(['oidc_enabled' => true]);
    }

    public function testCallbackSuccessSetsSessionAndRedirectsToMe(): void
    {
        $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
            public function authenticate(): OidcClaims
            {
                return new OidcClaims(
                    subject: 'sub-1',
                    email: 'alice@example.com',
                    displayName: 'Alice',
                    groups: ['group-admin'],
                );
            }
        });
        $this->enqueueApiResponse(200, [
            'user_id' => 99, 'role' => 'admin', 'email' => 'alice@example.com',
            'display_name' => 'Alice', 'is_local' => false,
        ]);

        $response = $this->request('GET', '/oidc/callback');

        self::assertSame(302, $response->getStatusCode());
        self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
        self::assertSame(99, $_SESSION['_user']['user_id'] ?? null);
        self::assertSame('admin', $_SESSION['_user']['role']);
    }

    public function testNoneRoleRedirectsToNoAccess(): void
    {
        $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
            public function authenticate(): OidcClaims
            {
                return new OidcClaims('sub-x', 'x@x', 'X', []);
            }
        });
        $this->enqueueApiResponse(200, [
            'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
        ]);

        $response = $this->request('GET', '/oidc/callback');

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

    public function testHandshakeFailureRedirectsToLogin(): void
    {
        $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
            public function authenticate(): OidcClaims
            {
                throw new OidcException('state mismatch');
            }
        });

        $response = $this->request('GET', '/oidc/callback');

        self::assertSame(302, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertSame('error', $flash[0]['type']);
    }

    public function testInitiateRotatesSessionIdBeforeAuthenticate(): void
    {
        // SEC_REVIEW F9: the session id must be rotated at the top of
        // /login/oidc, BEFORE the authenticator stashes state/nonce/PKCE
        // in $_SESSION. An attacker who pre-fixated the victim's cookie
        // loses contact at this moment.
        //
        // Drive an initial request to establish a session id; capture it
        // and the id observed inside authenticate(); assert they differ.
        $this->request('GET', '/login');
        $preId = session_id();
        self::assertNotSame('', $preId);

        $capture = new \stdClass();
        $capture->idAtAuthenticate = null;
        $this->bindOidcAuthenticator(new class ($capture) implements OidcAuthenticator {
            public function __construct(private readonly \stdClass $capture)
            {
            }

            public function authenticate(): OidcClaims
            {
                $this->capture->idAtAuthenticate = session_id();
                // Short-circuit: we only care about the pre-handshake
                // rotation, not the rest of the OIDC flow.
                throw new OidcException('captured for test');
            }
        });

        $this->request('GET', '/login/oidc');

        self::assertIsString($capture->idAtAuthenticate);
        self::assertNotSame('', $capture->idAtAuthenticate);
        self::assertNotSame(
            $preId,
            $capture->idAtAuthenticate,
            'session id was not rotated before OIDC handshake (F9 regression)',
        );
    }

    public function testNoneRoleDoesNotLogRawSubject(): void
    {
        // SEC_REVIEW F34: a SIEM operator must not see the raw OIDC subject
        // in the no-role-assigned warning. A stable fingerprint is logged
        // instead so triage can still correlate repeated denials for the
        // same user.
        $rawSubject = 'aad-objectid-secret-9b6f7e3c-1234-5678';
        $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
            public function __construct(private readonly string $sub)
            {
            }

            public function authenticate(): OidcClaims
            {
                return new OidcClaims($this->sub, 'x@x', 'X', []);
            }
        });
        $this->enqueueApiResponse(200, [
            'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
        ]);
        $logs = $this->captureLogs();

        $this->request('GET', '/oidc/callback');

        self::assertNotEmpty($logs->getRecords(), 'expected the no-role denial to be logged');
        foreach ($logs->getRecords() as $record) {
            $serialized = json_encode([
                'message' => $record->message,
                'context' => $record->context,
            ], \JSON_THROW_ON_ERROR);
            self::assertStringNotContainsString($rawSubject, $serialized);
            self::assertArrayNotHasKey('subject', $record->context);
        }
        self::assertTrue($logs->hasWarningThatContains('oidc user has no role assigned'));
    }

    public function testDisabledUserDeniedDoesNotLogRawSubject(): void
    {
        // SEC_REVIEW F34: same redaction on the user_disabled branch.
        $rawSubject = 'aad-objectid-other-7c8d2f';
        $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
            public function __construct(private readonly string $sub)
            {
            }

            public function authenticate(): OidcClaims
            {
                return new OidcClaims($this->sub, 'x@x', 'X', []);
            }
        });
        $this->enqueueApiResponse(403, ['error' => 'user_disabled']);
        $logs = $this->captureLogs();

        $this->request('GET', '/oidc/callback');

        foreach ($logs->getRecords() as $record) {
            $serialized = json_encode([
                'message' => $record->message,
                'context' => $record->context,
            ], \JSON_THROW_ON_ERROR);
            self::assertStringNotContainsString($rawSubject, $serialized);
            self::assertArrayNotHasKey('subject', $record->context);
        }
        self::assertTrue($logs->hasWarningThatContains('oidc login denied: user disabled'));
    }

    public function testApiUnreachableDuringUpsertFlashesAndRedirects(): void
    {
        $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
            public function authenticate(): OidcClaims
            {
                return new OidcClaims('sub-1', null, 'Alice', []);
            }
        });
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'down',
            new \GuzzleHttp\Psr7\Request('POST', '/'),
        ));
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'down',
            new \GuzzleHttp\Psr7\Request('POST', '/'),
        ));

        $response = $this->request('GET', '/oidc/callback');

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