<?php

declare(strict_types=1);

namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;

use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

/**
 * Provider API Platform pour la resource AuditLog.
 *
 * Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
 *  - une instance unique d'AuditLogOutput (operation Get) ;
 *  - un DbalPaginator de AuditLogOutput (operation GetCollection).
 *
 * Le paginator implementant PaginatorInterface laisse API Platform generer
 * automatiquement la section `hydra:view` : aucune manipulation manuelle.
 *
 * Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
 * reservee a l'ecriture hors transaction ORM).
 */
final readonly class AuditLogProvider implements ProviderInterface
{
    public function __construct(
        #[Autowire(service: 'doctrine.dbal.default_connection')]
        private Connection $connection,
        private Pagination $pagination,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
    {
        if (!$operation instanceof CollectionOperationInterface) {
            return $this->provideItem((string) $uriVariables['id']);
        }

        return $this->provideCollection($operation, $context);
    }

    private function provideItem(string $id): ?AuditLogOutput
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
             FROM audit_log WHERE id = :id',
            ['id' => $id],
        );

        if (false === $row) {
            return null;
        }

        return $this->hydrate($row);
    }

    /**
     * @param array<string, mixed> $context
     */
    private function provideCollection(Operation $operation, array $context): DbalPaginator
    {
        // Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
        // ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
        // toujours forcee. `audit_log` est une table append-only a croissance
        // infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
        // usage front (pas de <select> alimente par l'audit). Le flag global
        // `pagination_client_enabled: true` reste donc volontairement inerte ici.
        //
        // `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
        // (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
        // `itemsPerPage` au max de la resource mais pas `page` ; on impose un
        // minimum a 1 cote provider.
        $page         = max(1, $this->pagination->getPage($context));
        $itemsPerPage = $this->pagination->getLimit($operation, $context);
        $offset       = ($page - 1) * $itemsPerPage;
        $filters      = $this->extractFilters($context['filters'] ?? []);

        $dataQuery = $this->buildBaseQuery()
            ->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
            ->orderBy('performed_at', 'DESC')
            // Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
            // totalement deterministe quand plusieurs lignes partagent la
            // meme timestamp (ex: batch fixture, bulk flush < 1µs).
            ->addOrderBy('id', 'DESC')
            ->setFirstResult($offset)
            ->setMaxResults($itemsPerPage)
        ;

        $countQuery = $this->buildBaseQuery()->select('COUNT(*)');

        $this->applyFilters($dataQuery, $filters);
        $this->applyFilters($countQuery, $filters);

        /** @var list<array<string, mixed>> $rows */
        $rows       = $dataQuery->executeQuery()->fetchAllAssociative();
        $totalItems = (int) $countQuery->executeQuery()->fetchOne();

        $items = array_map(fn (array $row) => $this->hydrate($row), $rows);

        return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
    }

    private function buildBaseQuery(): QueryBuilder
    {
        return $this->connection->createQueryBuilder()->from('audit_log');
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
     */
    private function extractFilters(array $raw): array
    {
        $filters = [];

        // `entity_type` accepte soit une chaine, soit une liste (query syntax
        // `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
        // multi-selection cote front. On normalise en list<string> non-vide.
        if (isset($raw['entity_type'])) {
            if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
                $filters['entity_type'] = $raw['entity_type'];
            } elseif (is_array($raw['entity_type'])) {
                $cleaned = array_values(array_filter(
                    $raw['entity_type'],
                    static fn ($v): bool => is_string($v) && '' !== $v,
                ));
                if ([] !== $cleaned) {
                    $filters['entity_type'] = $cleaned;
                }
            }
        }

        foreach (['entity_id', 'performed_by'] as $key) {
            if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
                $filters[$key] = $raw[$key];
            }
        }

        // `action` : whitelist stricte. Un input hors-liste provoquait avant
        // un simple match vide (resultat 0 ligne) mais permettait d'incrementer
        // le log applicatif a chaque variation ; on rejette en 400 explicite.
        if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
            if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
                throw new BadRequestHttpException(
                    'Filtre "action" invalide : valeurs autorisees create|update|delete.',
                );
            }
            $filters['action'] = $raw['action'];
        }

        // Filtres de plage `performed_at[after]` / `performed_at[before]`.
        // Sans validation, un input malforme remonte jusqu'a Postgres qui
        // leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
        // 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
        // On valide en amont et on rejette en 400 explicite.
        if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
            $range = $raw['performed_at'];
            foreach (['after', 'before'] as $bound) {
                if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
                    continue;
                }
                if (false === strtotime($range[$bound])) {
                    throw new BadRequestHttpException(sprintf(
                        'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
                        $bound,
                    ));
                }
                $filters['performed_at_'.$bound] = $range[$bound];
            }
        }

        return $filters;
    }

    /**
     * @param array<string, list<string>|string> $filters
     */
    private function applyFilters(QueryBuilder $qb, array $filters): void
    {
        if (isset($filters['entity_type'])) {
            if (is_array($filters['entity_type'])) {
                $qb->andWhere('entity_type IN (:entity_types)')
                    ->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
                ;
            } else {
                $qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
            }
        }
        if (isset($filters['entity_id'])) {
            $qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
        }
        if (isset($filters['action'])) {
            $qb->andWhere('action = :action')->setParameter('action', $filters['action']);
        }
        if (isset($filters['performed_by'])) {
            // Recherche contains insensible a la casse pour matcher "adm" → "admin".
            // On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
            // interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
            // matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
            // deja le caractere d'echappement LIKE par defaut en PostgreSQL.
            $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
            $qb->andWhere('performed_by ILIKE :performed_by')
                ->setParameter('performed_by', '%'.$escaped.'%')
            ;
        }
        if (isset($filters['performed_at_after'])) {
            $qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
        }
        if (isset($filters['performed_at_before'])) {
            $qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
        }
    }

    /**
     * @param array<string, mixed> $row
     */
    private function hydrate(array $row): AuditLogOutput
    {
        /** @var string $rawChanges */
        $rawChanges = $row['changes'] ?? '{}';

        /** @var array<string, mixed> $changes */
        $changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);

        return new AuditLogOutput(
            id: (string) $row['id'],
            entityType: (string) $row['entity_type'],
            entityId: (string) $row['entity_id'],
            action: (string) $row['action'],
            changes: $changes,
            performedBy: (string) $row['performed_by'],
            performedAt: new DateTimeImmutable((string) $row['performed_at']),
            ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
            requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
        );
    }
}
