<?php

declare(strict_types=1);

namespace App\Tests\Integration\Auth;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Tests\Integration\Support\AppTestCase;

/**
 * Behavioural tests for the /api/v1/auth/* endpoints. These verify the
 * upsert flows and OIDC role resolution from group mappings.
 */
final class AuthEndpointsTest extends AppTestCase
{
    public function testUpsertLocalCreatesUserOnFirstCall(): void
    {
        $token = $this->createToken(TokenKind::Service);

        // No local row exists yet; the first upsert mints it.
        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-local',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode(['username' => 'admin']) ?: null
        );
        self::assertSame(200, $response->getStatusCode());

        $body = $this->decode($response);
        self::assertIsInt($body['user_id']);
        self::assertSame('admin', $body['role']);
        self::assertNull($body['email']);
        self::assertSame('admin', $body['display_name']);
        self::assertTrue($body['is_local']);

        self::assertSame(
            1,
            (int) $this->db->fetchOne('SELECT COUNT(*) FROM users WHERE is_local = 1'),
            'first call must create exactly one local row'
        );
    }

    /**
     * SEC_REVIEW F3 regression. A service-token holder must not be able
     * to mint additional Admin user rows by rotating the `username` field
     * over `POST /api/v1/auth/users/upsert-local`. Before the fix every
     * fresh username produced a new is_local=1, role=admin row that the
     * caller could then impersonate via X-Acting-User-Id.
     */
    public function testRotatingUsernamesNeverCreatesAdditionalLocalAdmins(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $adminId = $this->createUser(Role::Admin, isLocal: true);

        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json',
        ];

        $userIds = [];
        foreach (['attacker-1', 'attacker-2', 'attacker-3'] as $name) {
            $body = $this->decode($this->request(
                'POST',
                '/api/v1/auth/users/upsert-local',
                $headers,
                json_encode(['username' => $name]) ?: null
            ));
            $userIds[] = $body['user_id'];
            self::assertSame($adminId, $body['user_id']);
            self::assertSame('admin', $body['role']);
            self::assertSame($name, $body['display_name']);
        }

        // Same user_id returned every call.
        self::assertSame([$adminId, $adminId, $adminId], $userIds);

        // And only one is_local=1 row in the DB.
        self::assertSame(
            1,
            (int) $this->db->fetchOne('SELECT COUNT(*) FROM users WHERE is_local = 1'),
            'rotating usernames must not mint additional local-admin rows'
        );
    }

    /**
     * SEC_REVIEW F5: account creation must be audited. The first
     * upsert-local emits a `user.created` row attributed to the
     * service-token call (kind=system, no acting user yet) so SOC
     * tooling can see when the local-admin row first comes into
     * existence.
     */
    public function testFirstUpsertLocalEmitsUserCreatedAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-local',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode(['username' => 'admin']) ?: null
        );
        self::assertSame(200, $response->getStatusCode());
        $userId = $this->decode($response)['user_id'];

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, action, target_type, target_id, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row, 'user.created audit row must exist');
        self::assertSame('system', $row['actor_kind']);
        self::assertSame('user', $row['target_type']);
        self::assertSame((string) $userId, $row['target_id']);
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('local', $details['source']);
        self::assertSame('admin', $details['display_name']);
        self::assertSame('admin', $details['role']);
    }

    public function testRotatingUsernamesEmitsOnlyOneUserCreatedAudit(): void
    {
        // The first upsert mints the row + emits user.created.
        // Subsequent renames update display_name but must NOT emit
        // user.created again (no new account is created).
        $token = $this->createToken(TokenKind::Service);
        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json',
        ];
        foreach (['admin', 'renamed-1', 'renamed-2'] as $name) {
            $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, json_encode(['username' => $name]) ?: null);
        }

        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.created'"
        );
        self::assertSame(1, $count, 'only the bootstrap call should emit user.created');
    }

    public function testNewOidcLoginEmitsUserCreatedAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode([
                'subject' => 'sub-new',
                'email' => 'newcomer@example.com',
                'display_name' => 'Newcomer',
                'groups' => [],
            ]) ?: null
        );
        self::assertSame(200, $response->getStatusCode());
        $userId = $this->decode($response)['user_id'];

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('user', $row['target_type']);
        self::assertSame((string) $userId, $row['target_id']);
        self::assertSame('newcomer@example.com', $row['target_label']);
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('oidc', $details['source']);
        self::assertSame('sub-new', $details['subject']);
        self::assertSame('viewer', $details['role']);
    }

    public function testOidcRoleDriftEmitsRoleChangedAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json',
        ];
        $this->db->insert('oidc_role_mappings', [
            'group_id' => 'admin-grp',
            'role' => Role::Admin->value,
        ]);

        // First login: admin role.
        $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
            'subject' => 'drift-sub',
            'email' => 'drift@example.com',
            'display_name' => 'Drift',
            'groups' => ['admin-grp'],
        ]) ?: null);

        // Second login: no admin group → role drops to default viewer.
        $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
            'subject' => 'drift-sub',
            'email' => 'drift@example.com',
            'display_name' => 'Drift',
            'groups' => [],
        ]) ?: null);

        $row = $this->db->fetchAssociative(
            "SELECT details_json FROM audit_log WHERE action = 'user.role_changed' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row, 'role drift must emit user.role_changed');
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('admin', $details['changes']['role']['from']);
        self::assertSame('viewer', $details['changes']['role']['to']);
    }

    /**
     * Defense-in-depth: even if application code regresses, the partial
     * unique index added in 20260504100000_add_unique_local_user_index
     * must reject a second is_local=1 insert.
     */
    public function testDbLayerRejectsSecondLocalAdminInsert(): void
    {
        $this->createUser(Role::Admin, isLocal: true);

        $this->expectException(\Doctrine\DBAL\Exception\UniqueConstraintViolationException::class);

        $this->db->insert('users', [
            'subject' => null,
            'email' => null,
            'display_name' => 'second-local',
            'role' => Role::Admin->value,
            'is_local' => 1,
        ]);
    }

    /**
     * SEC_REVIEW F12 regression. Even if direct DB tampering or a
     * compromised "data-fix" script tries to insert a row with
     * `is_local=1` AND a non-null `subject` (an OIDC identity flagged
     * as local), the migration
     * 20260505100000_add_users_local_subject_invariant rejects the
     * write — MySQL via CHECK constraint, SQLite via BEFORE INSERT
     * trigger.
     */
    public function testDbLayerRejectsInsertingLocalRowWithNonNullSubject(): void
    {
        $this->expectException(\Doctrine\DBAL\Exception::class);

        $this->db->insert('users', [
            'subject' => 'hijacked-oidc-sub',
            'email' => 'hijacked@example.com',
            'display_name' => 'admin',
            'role' => Role::Admin->value,
            'is_local' => 1,
        ]);
    }

    /**
     * SEC_REVIEW F12 regression. The classic threat model is "OIDC row
     * already exists, attacker flips its is_local to 1 via a data-fix
     * script". The BEFORE UPDATE trigger / CHECK constraint must catch
     * this transition too — not only fresh inserts.
     */
    public function testDbLayerRejectsFlippingIsLocalOnOidcRow(): void
    {
        $this->db->insert('users', [
            'subject' => 'oidc-sub',
            'email' => 'oidc@example.com',
            'display_name' => 'admin',
            'role' => Role::Viewer->value,
            'is_local' => 0,
        ]);
        $oidcId = (int) $this->db->lastInsertId();

        $this->expectException(\Doctrine\DBAL\Exception::class);

        $this->db->update('users', ['is_local' => 1], ['id' => $oidcId]);
    }

    /**
     * SEC_REVIEW F12 regression. Belt-and-suspenders to the DB-level
     * constraint: `findLocal()` must additionally filter by
     * `subject IS NULL`, so that even if some future code path bypasses
     * the constraint (or it is dropped during ops), a hijacked OIDC row
     * cannot bind to local-admin login. We cannot exercise this via a
     * direct insert (the DB constraint blocks it), so we drop the
     * constraint for the duration of the test, inject the malformed
     * row, and assert findLocal does not return it.
     */
    public function testFindLocalIgnoresHijackedRowEvenIfDbConstraintIsBypassed(): void
    {
        // Bypass the SQLite triggers so we can simulate a tampered DB.
        $this->db->executeStatement('DROP TRIGGER IF EXISTS trg_users_local_subject_null_insert');
        $this->db->executeStatement('DROP TRIGGER IF EXISTS trg_users_local_subject_null_update');

        $this->db->insert('users', [
            'subject' => 'oidc-sub-hijacked',
            'email' => 'hijack@example.com',
            'display_name' => 'admin',
            'role' => Role::Admin->value,
            'is_local' => 1,
        ]);

        /** @var \App\Infrastructure\Auth\UserRepository $users */
        $users = $this->container->get(\App\Infrastructure\Auth\UserRepository::class);

        self::assertNull($users->findLocal(), 'findLocal must skip rows with non-null subject');
    }

    public function testUpsertLocalIsIdempotent(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $adminId = $this->createUser(Role::Admin, isLocal: true);

        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $adminId,
            'Content-Type' => 'application/json',
        ];
        $body = json_encode(['username' => 'idempotent']) ?: null;

        $first = $this->decode($this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body));
        $second = $this->decode($this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body));

        self::assertSame($first['user_id'], $second['user_id']);
    }

    public function testUpsertOidcResolvesRoleFromGroups(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $adminId = $this->createUser(Role::Admin, isLocal: true);

        // Seed a role mapping: group "ops-group" → operator
        $this->db->insert('oidc_role_mappings', [
            'group_id' => 'ops-group',
            'role' => Role::Operator->value,
        ]);
        $this->db->insert('oidc_role_mappings', [
            'group_id' => 'admin-group',
            'role' => Role::Admin->value,
        ]);

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'X-Acting-User-Id' => (string) $adminId,
                'Content-Type' => 'application/json',
            ],
            json_encode([
                'subject' => 'sub-1',
                'email' => 'alice@example.com',
                'display_name' => 'Alice',
                'groups' => ['ops-group', 'admin-group'],
            ]) ?: null
        );
        self::assertSame(200, $response->getStatusCode());

        $body = $this->decode($response);
        self::assertSame('admin', $body['role'], 'highest matching role wins');
        self::assertSame('alice@example.com', $body['email']);
        self::assertSame('Alice', $body['display_name']);
        self::assertFalse($body['is_local']);
    }

    public function testUpsertOidcFallsBackToDefaultRoleWithNoMatchingGroup(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $adminId = $this->createUser(Role::Admin, isLocal: true);

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'X-Acting-User-Id' => (string) $adminId,
                'Content-Type' => 'application/json',
            ],
            json_encode([
                'subject' => 'sub-default',
                'email' => 'b@example.com',
                'display_name' => 'B',
                'groups' => ['unknown-group'],
            ]) ?: null
        );
        self::assertSame(200, $response->getStatusCode());

        // Default in tests is Role::Viewer.
        self::assertSame('viewer', $this->decode($response)['role']);
    }

    public function testUpsertOidcRecomputesRoleOnSubsequentLogins(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $adminId = $this->createUser(Role::Admin, isLocal: true);

        $this->db->insert('oidc_role_mappings', [
            'group_id' => 'g1',
            'role' => Role::Operator->value,
        ]);

        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $adminId,
            'Content-Type' => 'application/json',
        ];

        $first = $this->decode($this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            $headers,
            json_encode([
                'subject' => 'churn',
                'email' => 'c@example.com',
                'display_name' => 'C',
                'groups' => ['g1'],
            ]) ?: null
        ));
        self::assertSame('operator', $first['role']);

        // Subsequent login with no matching group → role drops to default viewer.
        $second = $this->decode($this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            $headers,
            json_encode([
                'subject' => 'churn',
                'email' => 'c@example.com',
                'display_name' => 'C',
                'groups' => [],
            ]) ?: null
        ));
        self::assertSame($first['user_id'], $second['user_id']);
        self::assertSame('viewer', $second['role']);
    }

    /**
     * SEC_REVIEW F33 regression. `OidcClaims->email` is nullable — some
     * IdPs (or Entra app configurations that omit the email scope) do
     * not release the claim, and the UI forwards `email: null` in that
     * case. The endpoint must accept the request, persist
     * `users.email = NULL`, and emit `user.created` with `target_label`
     * falling back to `display_name`.
     */
    public function testUpsertOidcAcceptsMissingEmail(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json',
        ];

        // No `email` key at all (key omitted).
        $response = $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
            'subject' => 'no-email-sub',
            'display_name' => 'No Email',
            'groups' => [],
        ]) ?: null);
        self::assertSame(200, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertNull($body['email']);
        self::assertSame('No Email', $body['display_name']);

        $row = $this->db->fetchAssociative(
            'SELECT email, display_name FROM users WHERE id = :id',
            ['id' => $body['user_id']],
        );
        self::assertIsArray($row);
        self::assertNull($row['email'], 'email column must persist NULL when not released');
        self::assertSame('No Email', $row['display_name']);

        $audit = $this->db->fetchAssociative(
            "SELECT target_label, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($audit);
        self::assertSame('No Email', $audit['target_label'], 'target_label falls back to display_name when email null');
        $details = json_decode((string) $audit['details_json'], true);
        self::assertIsArray($details);
        self::assertNull($details['email']);
    }

    public function testUpsertOidcAcceptsExplicitNullEmail(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $headers = [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json',
        ];

        // Explicit JSON null — what the UI sends when OidcClaims->email is null.
        $response = $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
            'subject' => 'null-email-sub',
            'email' => null,
            'display_name' => 'Null Email',
            'groups' => [],
        ]) ?: null);
        self::assertSame(200, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertNull($body['email']);

        $row = $this->db->fetchAssociative(
            'SELECT email FROM users WHERE id = :id',
            ['id' => $body['user_id']],
        );
        self::assertIsArray($row);
        self::assertNull($row['email']);
    }

    /**
     * SEC_REVIEW F33: subject and display_name remain required. Missing
     * either still 400s — the relaxation only applies to `email`.
     */
    public function testUpsertOidcStillRejectsMissingSubject(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode([
                'email' => 'x@example.com',
                'display_name' => 'X',
                'groups' => [],
            ]) ?: null
        );
        self::assertSame(400, $response->getStatusCode());
    }

    public function testUpsertOidcStillRejectsMissingDisplayName(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode([
                'subject' => 'x',
                'email' => 'x@example.com',
                'groups' => [],
            ]) ?: null
        );
        self::assertSame(400, $response->getStatusCode());
    }

    public function testUpsertOidcRejectsAdminToken(): void
    {
        // Even an admin token can't call /auth/* — those are service-only.
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-local',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            json_encode(['username' => 'admin']) ?: null
        );
        self::assertSame(403, $response->getStatusCode());
    }

    /**
     * SEC_REVIEW F17 regression: a successful `GET /api/v1/auth/users/{id}`
     * must emit `user.fetched` so iterative enumeration leaves a per-id
     * trail in audit_log.
     */
    public function testGetUserFoundEmitsUserFetchedAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $userId = $this->createUser(Role::Operator, isLocal: false);

        $response = $this->request(
            'GET',
            '/api/v1/auth/users/' . $userId,
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(200, $response->getStatusCode());

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.fetched' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row, 'user.fetched audit row must exist on 200');
        self::assertSame('system', $row['actor_kind']);
        self::assertSame('user', $row['target_type']);
        self::assertSame((string) $userId, $row['target_id']);
        self::assertSame('user@example.com', $row['target_label']);
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('found', $details['outcome']);
    }

    /**
     * SEC_REVIEW F17 regression: a 404 lookup must also emit
     * `user.fetched` with outcome=not_found. This is the path attackers
     * iterate over to enumerate which ids exist; without this row the
     * probe is invisible to SOC tooling.
     */
    public function testGetUserNotFoundEmitsUserFetchedAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);

        $response = $this->request(
            'GET',
            '/api/v1/auth/users/999999',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(404, $response->getStatusCode());

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.fetched' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row, 'user.fetched audit row must exist on 404');
        self::assertSame('system', $row['actor_kind']);
        self::assertSame('user', $row['target_type']);
        self::assertSame('999999', $row['target_id']);
        self::assertNull($row['target_label']);
        $details = json_decode((string) $row['details_json'], true);
        self::assertIsArray($details);
        self::assertSame('not_found', $details['outcome']);
    }

    /**
     * SEC_REVIEW F17: a malformed id (non-numeric, leading zero, etc.)
     * is rejected at the protocol layer with 400 and does NOT emit an
     * audit row. The audit signal we care about is keyed on a valid id
     * shape, where iterative enumeration becomes meaningful.
     */
    public function testGetUserInvalidIdDoesNotEmitAudit(): void
    {
        $token = $this->createToken(TokenKind::Service);

        $response = $this->request(
            'GET',
            '/api/v1/auth/users/abc',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(400, $response->getStatusCode());

        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.fetched'"
        );
        self::assertSame(0, $count, 'malformed-id 400 must not emit user.fetched');
    }

    /**
     * SEC_REVIEW F17: an iterative scan over many ids leaves one audit
     * row per probe. This is the SOC detection signal — a single
     * service-token id producing many `user.fetched` rows in a tight
     * window is the alert pattern.
     */
    public function testEnumerationProducesOneAuditRowPerProbe(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $headers = ['Authorization' => 'Bearer ' . $token];

        for ($i = 1; $i <= 5; $i++) {
            $this->request('GET', '/api/v1/auth/users/' . $i, $headers);
        }

        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.fetched'"
        );
        self::assertSame(5, $count, 'each /users/{id} probe must emit one audit row');
    }
}
