<?php

declare(strict_types=1);

namespace App\Tests\Integration\Auth;

use App\Domain\Auth\TokenIssuer;
use App\Domain\Auth\TokenKind;
use App\Infrastructure\Auth\ServiceTokenBootstrap;
use App\Tests\Integration\Support\AppTestCase;

final class ServiceTokenBootstrapTest extends AppTestCase
{
    public function testBootstrapInsertsServiceTokenRow(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);

        $raw = $issuer->issue(TokenKind::Service);
        $boot->bootstrap($raw);

        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'"
        );
        self::assertSame(1, $count);
    }

    public function testBootstrapIsIdempotent(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);

        $raw = $issuer->issue(TokenKind::Service);
        $boot->bootstrap($raw);
        $boot->bootstrap($raw);
        $boot->bootstrap($raw);

        self::assertSame(
            1,
            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
        );
    }

    public function testBootstrapWithEmptyTokenIsNoOp(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);

        $boot->bootstrap('');

        self::assertSame(
            0,
            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
        );
    }

    public function testBootstrapWithMalformedTokenIsNoOp(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);

        $boot->bootstrap('not-a-token');

        self::assertSame(
            0,
            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
        );
    }

    /**
     * SEC_REVIEW F13. The previous implementation left the old service-kind
     * row valid forever (the operator was expected to revoke it manually).
     * Bootstrap now revokes any previously-active service-kind row when a
     * new value is provisioned, so a leaked old token cannot authenticate
     * after the next bootstrap of a fresh value.
     */
    public function testBootstrapWithDifferentTokenRevokesPreviousAndInsertsNewRow(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);

        $first = $issuer->issue(TokenKind::Service);
        $second = $issuer->issue(TokenKind::Service);

        $boot->bootstrap($first);
        $boot->bootstrap($second);

        // Both rows are kept (audit + traceability), but only the second is active.
        self::assertSame(
            2,
            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
        );
        self::assertSame(
            1,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
            ),
            'old service token must be marked revoked after rotation'
        );
        self::assertSame(
            1,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
            ),
            'exactly one service token must remain active after rotation'
        );
    }

    /**
     * SEC_REVIEW F13. If the operator has accumulated multiple
     * service-kind rows (e.g. ran rotations on a pre-fix deploy),
     * a fresh bootstrap revokes ALL previously-valid service-kind rows,
     * not just the most recent one.
     */
    public function testBootstrapRotationRevokesEveryPreviouslyActiveServiceToken(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);

        // Simulate a pre-fix history with three accumulated active service rows.
        /** @var \App\Domain\Auth\TokenHasher $hasher */
        $hasher = $this->container->get(\App\Domain\Auth\TokenHasher::class);
        foreach (range(1, 3) as $_) {
            $raw = $issuer->issue(TokenKind::Service);
            $this->db->insert('api_tokens', [
                'token_hash' => $hasher->hash($raw),
                'token_prefix' => substr($raw, 0, 8),
                'kind' => 'service',
                'reporter_id' => null,
                'consumer_id' => null,
                'role' => null,
                'expires_at' => null,
                'revoked_at' => null,
                'last_used_at' => null,
            ]);
        }
        self::assertSame(
            3,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
            )
        );

        $fresh = $issuer->issue(TokenKind::Service);
        $boot->bootstrap($fresh);

        self::assertSame(
            3,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
            ),
            'all three pre-existing service tokens must be revoked'
        );
        self::assertSame(
            1,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
            ),
            'only the new service token must remain active'
        );
    }

    /**
     * SEC_REVIEW F13. Rotation must surface in the audit log so SOC tooling
     * can attribute the change-of-state. The `token.revoked` row carries
     * a structured `reason: rotated_by_bootstrap` so a query can split
     * automatic-rotation from operator-initiated revoke.
     */
    public function testBootstrapRotationEmitsRevokedAndCreatedAuditRows(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);

        $first = $issuer->issue(TokenKind::Service);
        $second = $issuer->issue(TokenKind::Service);

        $boot->bootstrap($first);
        $this->db->executeStatement("DELETE FROM audit_log");

        $boot->bootstrap($second);

        $revokedDetails = $this->db->fetchOne(
            "SELECT details_json FROM audit_log WHERE action = 'token.revoked' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsString($revokedDetails);
        $details = json_decode($revokedDetails, true);
        self::assertIsArray($details);
        self::assertSame('service', $details['kind']);
        self::assertSame('rotated_by_bootstrap', $details['reason']);

        $createdRow = $this->db->fetchAssociative(
            "SELECT actor_kind, details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($createdRow);
        self::assertSame('system', $createdRow['actor_kind']);
        $createdDetails = json_decode((string) $createdRow['details_json'], true);
        self::assertIsArray($createdDetails);
        self::assertSame('service', $createdDetails['kind']);
        self::assertSame('bootstrap', $createdDetails['source']);
        self::assertIsArray($createdDetails['rotated_from']);
        self::assertCount(1, $createdDetails['rotated_from']);
    }

    /**
     * SEC_REVIEW F13 corner case. If the operator put the old token back
     * into env after explicitly revoking it (intentionally or via env-var
     * rollback), bootstrap must NOT silently re-enable a known-bad hash.
     * It refuses; the operator must issue a fresh value.
     */
    public function testBootstrapRefusesToReEnablePreviouslyRevokedToken(): void
    {
        /** @var ServiceTokenBootstrap $boot */
        $boot = $this->container->get(ServiceTokenBootstrap::class);
        /** @var TokenIssuer $issuer */
        $issuer = $this->container->get(TokenIssuer::class);
        /** @var \App\Domain\Auth\TokenHasher $hasher */
        $hasher = $this->container->get(\App\Domain\Auth\TokenHasher::class);

        $raw = $issuer->issue(TokenKind::Service);
        $this->db->insert('api_tokens', [
            'token_hash' => $hasher->hash($raw),
            'token_prefix' => substr($raw, 0, 8),
            'kind' => 'service',
            'reporter_id' => null,
            'consumer_id' => null,
            'role' => null,
            'expires_at' => null,
            'revoked_at' => '2026-01-01 00:00:00',
            'last_used_at' => null,
        ]);

        $boot->bootstrap($raw);

        self::assertSame(
            1,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NOT NULL"
            ),
            'revoked row stays revoked'
        );
        self::assertSame(
            0,
            (int) $this->db->fetchOne(
                "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service' AND revoked_at IS NULL"
            ),
            'no fresh active row inserted from a revoked-hash env value'
        );
    }
}
