<?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;

/**
 * SEC_REVIEW F11: a disabled `users` row is unimpersonatable, refuses
 * OIDC re-login (no role recompute, no audit drift), and refuses local
 * sign-in. The admin user-CRUD endpoint is the only path that toggles
 * `disabled_at` — it audits both directions and refuses self-disable
 * and local-admin-disable.
 */
final class DisabledUserTest extends AppTestCase
{
    public function testServiceTokenImpersonatingDisabledUserReturns403(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-disabled', disabled: true);

        $response = $this->request('GET', '/api/v1/admin/me', [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $userId,
        ]);

        self::assertSame(403, $response->getStatusCode());
        self::assertSame('user_disabled', $this->decode($response)['error']);
    }

    public function testEnabledUserOfSameKindStillWorksWhenAnotherIsDisabled(): void
    {
        // Sanity: disabling user A must not break impersonation for user B.
        $token = $this->createToken(TokenKind::Service);
        $disabledId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-A', disabled: true);
        $activeId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-B', disabled: false);

        $disabled = $this->request('GET', '/api/v1/admin/me', [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $disabledId,
        ]);
        self::assertSame(403, $disabled->getStatusCode());

        $active = $this->request('GET', '/api/v1/admin/me', [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $activeId,
        ]);
        self::assertSame(200, $active->getStatusCode());
    }

    public function testUpsertOidcRefusesDisabledUserAndDoesNotRecomputeRole(): void
    {
        // A returning OIDC subject whose row was disabled must 403,
        // and the role on the (still disabled) row must remain
        // whatever it was — we must NOT have applied the new groups.
        $token = $this->createToken(TokenKind::Service);
        $this->db->insert('users', [
            'subject' => 'churn-disabled',
            'email' => 'old@example.com',
            'display_name' => 'Churned',
            'role' => Role::Viewer->value,
            'is_local' => 0,
            'disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
        ]);

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

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-oidc',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode([
                'subject' => 'churn-disabled',
                'email' => 'new@example.com',
                'display_name' => 'Renamed',
                'groups' => ['admin-grp'],
            ])
        );

        self::assertSame(403, $response->getStatusCode());
        self::assertSame('user_disabled', $this->decode($response)['error']);

        // Role must not have been recomputed.
        $row = $this->db->fetchAssociative(
            "SELECT role, email, display_name FROM users WHERE subject = 'churn-disabled'"
        );
        self::assertIsArray($row);
        self::assertSame('viewer', $row['role'], 'disabled user role must not be recomputed from new groups');
        self::assertSame('old@example.com', $row['email']);
        self::assertSame('Churned', $row['display_name']);

        // No user.role_changed audit row from this denied attempt.
        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.role_changed'"
        );
        self::assertSame(0, $count, 'denied login must not emit role_changed audit');
    }

    public function testUpsertLocalRefusesWhenLocalAdminDisabled(): void
    {
        $token = $this->createToken(TokenKind::Service);
        // Seed the local admin row pre-disabled.
        $this->createUser(Role::Admin, isLocal: true, disabled: true);

        $response = $this->request(
            'POST',
            '/api/v1/auth/users/upsert-local',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['username' => 'admin'])
        );

        self::assertSame(403, $response->getStatusCode());
        self::assertSame('user_disabled', $this->decode($response)['error']);
    }

    public function testActorViaIsLocalForServiceImpersonationOfLocalAdmin(): void
    {
        // Layer B2: any audit emitted while impersonating a local user
        // carries actor_via='local'; an OIDC user yields 'oidc'.
        $token = $this->createToken(TokenKind::Service);
        $localAdmin = $this->createUser(Role::Admin, isLocal: true);
        $oidcAdmin = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-via-oidc');

        // Drive an audit-emitting write under the local admin.
        $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            [
                'Authorization' => 'Bearer ' . $token,
                'X-Acting-User-Id' => (string) $localAdmin,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.1', 'reason' => 'via=local'])
        );

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, actor_id, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('user', $row['actor_kind']);
        self::assertSame((string) $localAdmin, $row['actor_id']);
        self::assertSame('local', $row['actor_via']);

        // Same write under the OIDC admin → actor_via='oidc'.
        $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            [
                'Authorization' => 'Bearer ' . $token,
                'X-Acting-User-Id' => (string) $oidcAdmin,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.2', 'reason' => 'via=oidc'])
        );

        $row = $this->db->fetchAssociative(
            "SELECT actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('oidc', $row['actor_via']);
    }

    public function testActorViaIsAdminTokenForBareAdminToken(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

        $this->request(
            'POST',
            '/api/v1/admin/manual-blocks',
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.3', 'reason' => 'via=admin-token'])
        );

        $row = $this->db->fetchAssociative(
            "SELECT actor_kind, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame('admin-token', $row['actor_kind']);
        self::assertSame('admin-token', $row['actor_via']);
    }

    public function testAdminUsersListIncludesDisabledFlag(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $active = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-active');
        $disabled = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-off', disabled: true);

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

        $body = $this->decode($response);
        $byId = [];
        foreach ($body['items'] as $item) {
            $byId[(int) $item['id']] = $item;
        }
        self::assertArrayHasKey($active, $byId);
        self::assertArrayHasKey($disabled, $byId);
        self::assertFalse($byId[$active]['disabled']);
        self::assertNull($byId[$active]['disabled_at']);
        self::assertTrue($byId[$disabled]['disabled']);
        self::assertNotNull($byId[$disabled]['disabled_at']);
    }

    public function testAdminPatchDisablesUserAndEmitsAudit(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'patch-target');

        $response = $this->request(
            'PATCH',
            '/api/v1/admin/users/' . $target,
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['disabled' => true])
        );
        self::assertSame(200, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertTrue($body['disabled']);
        self::assertNotNull($body['disabled_at']);

        $row = $this->db->fetchAssociative(
            "SELECT action, target_id, actor_kind FROM audit_log WHERE action = 'user.disabled' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($row);
        self::assertSame((string) $target, $row['target_id']);

        // Reverse — flipping back emits user.enabled.
        $back = $this->request(
            'PATCH',
            '/api/v1/admin/users/' . $target,
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['disabled' => false])
        );
        self::assertSame(200, $back->getStatusCode());
        self::assertFalse($this->decode($back)['disabled']);

        $enabledRow = $this->db->fetchAssociative(
            "SELECT action FROM audit_log WHERE action = 'user.enabled' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($enabledRow);
    }

    public function testAdminPatchRefusesSelfDisable(): void
    {
        $token = $this->createToken(TokenKind::Service);
        $self = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-self');

        $response = $this->request(
            'PATCH',
            '/api/v1/admin/users/' . $self,
            [
                'Authorization' => 'Bearer ' . $token,
                'X-Acting-User-Id' => (string) $self,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['disabled' => true])
        );
        self::assertSame(409, $response->getStatusCode());
        self::assertSame('cannot_disable_self', $this->decode($response)['error']);

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

    public function testAdminPatchRefusesDisablingLocalAdmin(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $local = $this->createUser(Role::Admin, isLocal: true);

        $response = $this->request(
            'PATCH',
            '/api/v1/admin/users/' . $local,
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['disabled' => true])
        );
        self::assertSame(409, $response->getStatusCode());
        self::assertSame('cannot_disable_local_admin', $this->decode($response)['error']);
    }

    public function testAdminPatchIsIdempotentWithNoAuditOnNoOp(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-noop');

        // Already enabled → patch enabled=false (no, we want already-active, set enabled): set disabled=false (no-op).
        $beforeAuditCount = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
        );

        $response = $this->request(
            'PATCH',
            '/api/v1/admin/users/' . $target,
            [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type' => 'application/json',
            ],
            (string) json_encode(['disabled' => false])
        );
        self::assertSame(200, $response->getStatusCode());

        $afterAuditCount = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
        );
        self::assertSame($beforeAuditCount, $afterAuditCount, 'no-op patch must not emit audit');
    }

    public function testAdminUsersRequiresAdminRole(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);

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

    public function testAuditLogActorViaFilter(): void
    {
        // Filter on actor_via='local' returns only impersonated-local rows.
        $token = $this->createToken(TokenKind::Service);
        $local = $this->createUser(Role::Admin, isLocal: true);
        $oidc = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-filter');

        $localToken = $this->createToken(TokenKind::Admin, role: Role::Admin);

        // Three writes: two impersonated (one local, one oidc) + one admin token.
        $this->request('POST', '/api/v1/admin/manual-blocks', [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $local,
            'Content-Type' => 'application/json',
        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.1', 'reason' => 'l']));
        $this->request('POST', '/api/v1/admin/manual-blocks', [
            'Authorization' => 'Bearer ' . $token,
            'X-Acting-User-Id' => (string) $oidc,
            'Content-Type' => 'application/json',
        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.2', 'reason' => 'o']));
        $this->request('POST', '/api/v1/admin/manual-blocks', [
            'Authorization' => 'Bearer ' . $localToken,
            'Content-Type' => 'application/json',
        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.3', 'reason' => 'a']));

        // Admin-list filtered to actor_via=local.
        $resp = $this->request(
            'GET',
            '/api/v1/admin/audit-log?actor_via=local',
            ['Authorization' => 'Bearer ' . $localToken],
        );
        self::assertSame(200, $resp->getStatusCode());

        $body = $this->decode($resp);
        $vias = array_map(static fn (array $row) => $row['actor_via'], $body['items']);
        self::assertNotEmpty($vias);
        foreach ($vias as $via) {
            self::assertSame('local', $via);
        }
    }
}
