<?php

declare(strict_types=1);

namespace App\Infrastructure\Auth;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditContext;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Auth\Token;
use App\Domain\Auth\TokenHasher;
use App\Domain\Auth\TokenKind;
use App\Domain\Time\Clock;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;

/**
 * Ensures the api_tokens table contains a row matching the configured
 * UI_SERVICE_TOKEN. Idempotent — safe to call on every container start.
 *
 * - empty env → log a warning and skip (early-bring-up scenario).
 * - hash matches an existing service-kind row → no-op.
 * - hash absent → insert.
 * - hash absent but a *different* service-kind row exists (rotation):
 *   atomically revoke every previously-valid service-kind row and insert
 *   the new one. Emits `token.revoked` (per old) and `token.created` (new)
 *   audit rows attributed to `system` (SEC_REVIEW F13). The transaction
 *   keeps "no service token exists" out of any observable state — if the
 *   insert fails, the revokes roll back too, leaving the prior token
 *   valid.
 */
final class ServiceTokenBootstrap
{
    public function __construct(
        private readonly TokenRepository $tokens,
        private readonly TokenHasher $hasher,
        private readonly LoggerInterface $logger,
        private readonly Connection $connection,
        private readonly AuditEmitter $audit,
        private readonly Clock $clock,
    ) {
    }

    public function bootstrap(string $rawToken): void
    {
        if ($rawToken === '') {
            $this->logger->warning('UI_SERVICE_TOKEN is empty; skipping service-token bootstrap');

            return;
        }

        $parsed = Token::parse($rawToken);
        if ($parsed === null || $parsed->kind !== TokenKind::Service) {
            $this->logger->warning('UI_SERVICE_TOKEN is not a valid `irdb_svc_…` token; skipping');

            return;
        }

        $hash = $this->hasher->hash($rawToken);
        $existing = $this->tokens->findByHashIncludingInvalid($hash);

        if ($existing !== null) {
            if ($existing->kind === TokenKind::Service) {
                if ($existing->revokedAt !== null) {
                    // Operator explicitly revoked this hash, then put it
                    // back in env. Refuse to silently un-revoke; force the
                    // operator to issue a fresh value.
                    $this->logger->error(
                        'UI_SERVICE_TOKEN matches a previously-revoked row; refusing to re-enable. '
                        . 'Issue a fresh token value.'
                    );

                    return;
                }

                $this->logger->info('UI_SERVICE_TOKEN already provisioned; no-op');

                return;
            }

            // A non-service token with the same hash should be impossible
            // (160 bits of entropy). If it ever happens, refuse to clobber.
            $this->logger->error('UI_SERVICE_TOKEN hash collides with non-service row; refusing to insert');

            return;
        }

        // No existing row by exact hash. SEC_REVIEW F13: if there are any
        // currently-valid service-kind rows, we are in a rotation —
        // revoke them all atomically with the new insert so the previously
        // valid hash cannot authenticate after this bootstrap returns.
        $previouslyActive = $this->tokens->findActiveServiceTokens();
        $newPrefix = $parsed->prefix();

        $auditCtx = AuditContext::system();
        $now = $this->clock->now();

        $this->connection->transactional(function () use ($previouslyActive, $hash, $newPrefix, $auditCtx, $now): void {
            foreach ($previouslyActive as $old) {
                if ($old->id === null) {
                    // Repository hydrates persisted rows; id is always set.
                    throw new \LogicException('persisted TokenRecord must have id');
                }
                $this->tokens->revoke($old->id, $now);
                $this->audit->emitOrThrow(
                    AuditAction::TOKEN_REVOKED,
                    'token',
                    $old->id,
                    [
                        'kind' => $old->kind->value,
                        'prefix' => $old->prefix,
                        'reason' => 'rotated_by_bootstrap',
                    ],
                    $auditCtx,
                    self::tokenLabel($old->kind->value, $old->prefix),
                );
            }

            $this->tokens->create(new TokenRecord(
                id: null,
                kind: TokenKind::Service,
                hash: $hash,
                prefix: $newPrefix,
                reporterId: null,
                consumerId: null,
                role: null,
                expiresAt: null,
                revokedAt: null,
                lastUsedAt: null,
            ));

            $created = $this->tokens->findByHashIncludingInvalid($hash);
            if ($created === null) {
                throw new \RuntimeException('service token not retrievable after insert');
            }

            $this->audit->emitOrThrow(
                AuditAction::TOKEN_CREATED,
                'token',
                $created->id,
                [
                    'kind' => $created->kind->value,
                    'prefix' => $created->prefix,
                    'source' => 'bootstrap',
                    'rotated_from' => array_map(static fn (TokenRecord $r): string => $r->prefix, $previouslyActive),
                ],
                $auditCtx,
                self::tokenLabel($created->kind->value, $created->prefix),
            );
        });

        if ($previouslyActive !== []) {
            $this->logger->warning(
                'UI_SERVICE_TOKEN rotated; revoked previously-valid service token(s) and inserted new one',
                [
                    'revoked_count' => count($previouslyActive),
                    'revoked_prefixes' => array_map(
                        static fn (TokenRecord $r): string => $r->prefix,
                        $previouslyActive,
                    ),
                    'new_prefix' => $newPrefix,
                ],
            );

            return;
        }

        $this->logger->info('UI_SERVICE_TOKEN provisioned');
    }

    private static function tokenLabel(string $kind, string $prefix): string
    {
        return $kind . ':' . $prefix;
    }
}
