<?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 F16: admin tokens carry the issuing user's id; the auth
 * middleware refuses tokens whose issuer is later disabled or demoted
 * below the bound role.
 *
 * Reporter / consumer / service tokens stay user-less (they are device
 * credentials, not delegated user privilege) — covered here only to assert
 * the binding is admin-only.
 *
 * Tokens minted before this migration (and via `bin/console
 * tokens:create`) carry `user_id = NULL`; they are grandfathered, exercise
 * `testLegacyUnboundAdminTokenStillAuthenticates` below.
 */
final class TokenIssuerBindingTest extends AppTestCase
{
    public function testAdminTokenCreatedViaApiIsBoundToActingAdmin(): void
    {
        $service = $this->createToken(TokenKind::Service);
        $issuer = $this->createUser(Role::Admin);

        $resp = $this->request(
            'POST',
            '/api/v1/admin/tokens',
            [
                'Authorization' => 'Bearer ' . $service,
                'X-Acting-User-Id' => (string) $issuer,
                'Content-Type' => 'application/json',
            ],
            json_encode(['kind' => 'admin', 'role' => 'operator']) ?: null,
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertSame($issuer, (int) $body['user_id']);

        // The audit row carries the issuer id too — so a SOC reader can
        // attribute the mint without joining api_tokens.
        $auditRow = $this->db->fetchAssociative(
            "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($auditRow);
        $details = json_decode((string) $auditRow['details_json'], true);
        self::assertIsArray($details);
        self::assertSame($issuer, (int) $details['user_id']);
    }

    public function testReporterTokenCreatedViaApiIsNotBoundToUser(): void
    {
        $service = $this->createToken(TokenKind::Service);
        $issuer = $this->createUser(Role::Admin);
        $reporterId = $this->createReporter('rep-bind-check');

        $resp = $this->request(
            'POST',
            '/api/v1/admin/tokens',
            [
                'Authorization' => 'Bearer ' . $service,
                'X-Acting-User-Id' => (string) $issuer,
                'Content-Type' => 'application/json',
            ],
            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
        );
        self::assertSame(201, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertArrayHasKey('user_id', $body);
        self::assertNull($body['user_id']);
    }

    public function testListSurfacesIssuerLabel(): void
    {
        $service = $this->createToken(TokenKind::Service);
        $issuer = $this->createUser(Role::Admin);
        $this->db->update('users', ['display_name' => 'Carol Admin'], ['id' => $issuer]);

        // Mint via the API so the binding actually fires.
        $this->request(
            'POST',
            '/api/v1/admin/tokens',
            [
                'Authorization' => 'Bearer ' . $service,
                'X-Acting-User-Id' => (string) $issuer,
                'Content-Type' => 'application/json',
            ],
            json_encode(['kind' => 'admin', 'role' => 'viewer']) ?: null,
        );

        $list = $this->request('GET', '/api/v1/admin/tokens', [
            'Authorization' => 'Bearer ' . $service,
            'X-Acting-User-Id' => (string) $issuer,
        ]);
        self::assertSame(200, $list->getStatusCode());
        $rows = $this->decode($list)['data'];

        $minted = null;
        foreach ($rows as $row) {
            if (($row['kind'] ?? null) === 'admin' && ($row['role'] ?? null) === 'viewer') {
                $minted = $row;
                break;
            }
        }
        self::assertIsArray($minted, 'minted admin row not present in list');
        self::assertSame($issuer, (int) $minted['user_id']);
        self::assertSame('Carol Admin', $minted['user_label']);
    }

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

        // Pick any admin-only endpoint that returns 200 for an Admin role.
        $resp = $this->request('GET', '/api/v1/admin/tokens', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $resp->getStatusCode());
    }

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

        $this->db->update(
            'users',
            ['disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s')],
            ['id' => $issuer],
        );

        $resp = $this->request('GET', '/api/v1/admin/tokens', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(401, $resp->getStatusCode());
        self::assertSame('unauthorized', $this->decode($resp)['error']);
    }

    public function testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole(): void
    {
        $issuer = $this->createUser(Role::Admin);
        // Token grants Admin to whoever holds it.
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer);

        // Issuer demoted to Viewer — the admin-grant token must stop working.
        $this->db->update('users', ['role' => Role::Viewer->value], ['id' => $issuer]);

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

    public function testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole(): void
    {
        $issuer = $this->createUser(Role::Admin);
        // Token grants only Viewer; issuer remains Admin (>= Viewer) → token works.
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer, userId: $issuer);

        // Endpoint requires Viewer — admin/jobs/status is Viewer-tier.
        $resp = $this->request('GET', '/api/v1/admin/jobs/status', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $resp->getStatusCode());
    }

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

        // Simulate a hard delete: the issuer row is removed but the token
        // row points at the (now-orphaned) user_id. On MySQL the FK CASCADE
        // would kill the token row first; on SQLite the application-layer
        // user lookup returns null and the middleware refuses the token.
        $this->db->delete('users', ['id' => $issuer]);

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

    public function testLegacyUnboundAdminTokenStillAuthenticates(): void
    {
        // Admin token created without any user_id — legacy / console-issued.
        // F16 grandfathers these so existing deployments keep working;
        // operators rotate them after they redeploy.
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: null);

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