<?php

declare(strict_types=1);

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\User;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;

final readonly class ExpiredRegistrationPurger
{
    public const int DEFAULT_BATCH_SIZE = 500;
    public const int DEFAULT_MAX_BATCHES = 20;
    public const int EXPIRATION_GRACE_DAYS = 7;
    public const int MAX_BATCH_SIZE = 1000;
    public const int MAX_BATCHES = 100;

    private const array TECHNICAL_EMAIL_TYPES = [
        'account_activation',
        'password_reset',
    ];

    public function __construct(
        private Connection $connection,
    ) {
    }

    public function purge(
        ?\DateTimeImmutable $now = null,
        int $batchSize = self::DEFAULT_BATCH_SIZE,
        int $maxBatches = self::DEFAULT_MAX_BATCHES,
        bool $dryRun = false,
    ): ExpiredRegistrationPurgeResult {
        if ($batchSize < 1 || $batchSize > self::MAX_BATCH_SIZE) {
            throw new \InvalidArgumentException(sprintf('La taille de lot doit etre comprise entre 1 et %d.', self::MAX_BATCH_SIZE));
        }

        if ($maxBatches < 1 || $maxBatches > self::MAX_BATCHES) {
            throw new \InvalidArgumentException(sprintf('Le nombre maximal de lots doit etre compris entre 1 et %d.', self::MAX_BATCHES));
        }

        $now ??= new \DateTimeImmutable();
        $purgeBefore = $now->modify(sprintf('-%d days', self::EXPIRATION_GRACE_DAYS));

        if ($dryRun) {
            $eligibleCount = $this->countEligibleRegistrations($now, $purgeBefore);

            return new ExpiredRegistrationPurgeResult(
                eligibleCount: $eligibleCount,
                deletedCount: 0,
                skippedCount: 0,
                emailLogsDeleted: 0,
                auditRowsDeleted: 0,
                batchCount: 0,
                hasMore: $eligibleCount > $batchSize * $maxBatches,
            );
        }

        $eligibleCount = 0;
        $deletedCount = 0;
        $emailLogsDeleted = 0;
        $auditRowsDeleted = 0;
        $batchCount = 0;
        $lastBatchWasFull = false;
        $stoppedAfterSkippedRegistration = false;

        while ($batchCount < $maxBatches) {
            $batch = $this->connection->transactional(
                fn (Connection $connection): ExpiredRegistrationPurgeResult => $this->purgeBatch(
                    $connection,
                    $now,
                    $purgeBefore,
                    $batchSize,
                )
            );

            if (0 === $batch->eligibleCount) {
                break;
            }

            ++$batchCount;
            $eligibleCount += $batch->eligibleCount;
            $deletedCount += $batch->deletedCount;
            $emailLogsDeleted += $batch->emailLogsDeleted;
            $auditRowsDeleted += $batch->auditRowsDeleted;
            $lastBatchWasFull = $batch->eligibleCount === $batchSize;

            if ($batch->skippedCount > 0) {
                $stoppedAfterSkippedRegistration = true;

                break;
            }

            if (!$lastBatchWasFull) {
                break;
            }
        }

        $hasMore = $stoppedAfterSkippedRegistration
            || ($lastBatchWasFull
                && $batchCount === $maxBatches
                && $this->hasEligibleRegistration($now, $purgeBefore));

        return new ExpiredRegistrationPurgeResult(
            eligibleCount: $eligibleCount,
            deletedCount: $deletedCount,
            skippedCount: $eligibleCount - $deletedCount,
            emailLogsDeleted: $emailLogsDeleted,
            auditRowsDeleted: $auditRowsDeleted,
            batchCount: $batchCount,
            hasMore: $hasMore,
        );
    }

    private function purgeBatch(
        Connection $connection,
        \DateTimeImmutable $now,
        \DateTimeImmutable $purgeBefore,
        int $batchSize,
    ): ExpiredRegistrationPurgeResult {
        /** @var list<array{id: string, email: string}> $registrations */
        $registrations = $connection->fetchAllAssociative(
            sprintf(
                <<<'SQL'
                    SELECT registration.id::text AS id, registration.email
                    FROM user_user registration
                    WHERE %s
                    ORDER BY registration.email_verification_expires_at ASC, registration.id ASC
                    LIMIT :batchSize
                    FOR UPDATE OF registration SKIP LOCKED
                    SQL,
                $this->eligibilitySql('registration'),
            ),
            [
                'now' => $now,
                'purgeBefore' => $purgeBefore,
                'batchSize' => $batchSize,
                'technicalEmailTypes' => self::TECHNICAL_EMAIL_TYPES,
            ],
            [
                'now' => Types::DATETIME_IMMUTABLE,
                'purgeBefore' => Types::DATETIME_IMMUTABLE,
                'batchSize' => Types::INTEGER,
                'technicalEmailTypes' => ArrayParameterType::STRING,
            ],
        );

        if ([] === $registrations) {
            return new ExpiredRegistrationPurgeResult(0, 0, 0, 0, 0, 0, false);
        }

        $candidateIds = array_column($registrations, 'id');
        /** @var list<array{id: string, user_id: string}> $technicalEmailLogs */
        $technicalEmailLogs = $connection->fetchAllAssociative(
            <<<'SQL'
                SELECT id::text AS id, user_id::text AS user_id
                FROM mailer_email_log
                WHERE user_id IN (:candidateIds)
                  AND type IN (:emailTypes)
                SQL,
            [
                'candidateIds' => $candidateIds,
                'emailTypes' => self::TECHNICAL_EMAIL_TYPES,
            ],
            [
                'candidateIds' => ArrayParameterType::STRING,
                'emailTypes' => ArrayParameterType::STRING,
            ],
        );
        /** @var list<string> $deletedIds */
        $deletedIds = $connection->fetchFirstColumn(
            sprintf(
                <<<'SQL'
                    DELETE FROM user_user registration
                    WHERE registration.id IN (:candidateIds)
                      AND %s
                    RETURNING registration.id::text
                    SQL,
                $this->eligibilitySql('registration'),
            ),
            [
                'candidateIds' => $candidateIds,
                'now' => $now,
                'purgeBefore' => $purgeBefore,
                'technicalEmailTypes' => self::TECHNICAL_EMAIL_TYPES,
            ],
            [
                'candidateIds' => ArrayParameterType::STRING,
                'now' => Types::DATETIME_IMMUTABLE,
                'purgeBefore' => Types::DATETIME_IMMUTABLE,
                'technicalEmailTypes' => ArrayParameterType::STRING,
            ],
        );

        if ([] === $deletedIds) {
            return new ExpiredRegistrationPurgeResult(\count($registrations), 0, \count($registrations), 0, 0, 1, false);
        }

        $deletedIdMap = array_fill_keys($deletedIds, true);
        $technicalEmailLogIds = [];

        foreach ($technicalEmailLogs as $technicalEmailLog) {
            if (!isset($deletedIdMap[$technicalEmailLog['user_id']])) {
                continue;
            }

            $technicalEmailLogIds[] = $technicalEmailLog['id'];
        }

        $technicalEmailLogIds = array_values(array_unique($technicalEmailLogIds));
        $emailLogsDeleted = [] === $technicalEmailLogIds ? 0 : $connection->executeStatement(
            'DELETE FROM mailer_email_log WHERE id IN (:emailLogIds)',
            ['emailLogIds' => $technicalEmailLogIds],
            ['emailLogIds' => ArrayParameterType::STRING],
        );
        $auditRowsDeleted = $connection->executeStatement(
            'DELETE FROM user_user_audit WHERE object_id IN (:userIds)',
            ['userIds' => $deletedIds],
            ['userIds' => ArrayParameterType::STRING],
        );

        return new ExpiredRegistrationPurgeResult(
            eligibleCount: \count($registrations),
            deletedCount: \count($deletedIds),
            skippedCount: \count($registrations) - \count($deletedIds),
            emailLogsDeleted: $emailLogsDeleted,
            auditRowsDeleted: $auditRowsDeleted,
            batchCount: 1,
            hasMore: false,
        );
    }

    private function countEligibleRegistrations(
        \DateTimeImmutable $now,
        \DateTimeImmutable $purgeBefore,
    ): int {
        return (int) $this->connection->fetchOne(
            sprintf(
                'SELECT COUNT(*) FROM user_user registration WHERE %s',
                $this->eligibilitySql('registration'),
            ),
            [
                'now' => $now,
                'purgeBefore' => $purgeBefore,
                'technicalEmailTypes' => self::TECHNICAL_EMAIL_TYPES,
            ],
            [
                'now' => Types::DATETIME_IMMUTABLE,
                'purgeBefore' => Types::DATETIME_IMMUTABLE,
                'technicalEmailTypes' => ArrayParameterType::STRING,
            ],
        );
    }

    private function hasEligibleRegistration(
        \DateTimeImmutable $now,
        \DateTimeImmutable $purgeBefore,
    ): bool {
        return false !== $this->connection->fetchOne(
            sprintf(
                'SELECT 1 FROM user_user registration WHERE %s LIMIT 1',
                $this->eligibilitySql('registration'),
            ),
            [
                'now' => $now,
                'purgeBefore' => $purgeBefore,
                'technicalEmailTypes' => self::TECHNICAL_EMAIL_TYPES,
            ],
            [
                'now' => Types::DATETIME_IMMUTABLE,
                'purgeBefore' => Types::DATETIME_IMMUTABLE,
                'technicalEmailTypes' => ArrayParameterType::STRING,
            ],
        );
    }

    private function eligibilitySql(string $alias): string
    {
        return sprintf(
            <<<'SQL'
                %1$s.is_verified = FALSE
                AND %1$s.email_verification_token IS NOT NULL
                AND %1$s.email_verification_expires_at IS NOT NULL
                AND %1$s.email_verification_expires_at <= :purgeBefore
                AND %1$s.updated_at <= :purgeBefore
                AND %1$s.last_login_at IS NULL
                AND %1$s.invoice_address_id IS NULL
                AND %1$s.delivery_address_id IS NULL
                AND %1$s.group_id IS NULL
                AND (
                    %1$s.reset_password_token IS NULL
                    OR (
                        %1$s.reset_password_expires_at IS NOT NULL
                        AND %1$s.reset_password_expires_at <= :now
                    )
                )
                AND COALESCE(%1$s.roles::jsonb, '[]'::jsonb) <@ '["ROLE_USER"]'::jsonb
                AND NOT EXISTS (
                    SELECT 1
                    FROM cart_cart linked_cart
                    WHERE linked_cart.user_id = %1$s.id
                )
                AND NOT EXISTS (
                    SELECT 1
                    FROM cart_promo assigned_promo
                    WHERE assigned_promo.assigned_user_id = %1$s.id
                )
                AND NOT EXISTS (
                    SELECT 1
                    FROM mailer_email_log business_log
                    WHERE business_log.user_id = %1$s.id
                      AND business_log.type NOT IN (:technicalEmailTypes)
                )
                AND NOT EXISTS (
                    SELECT 1
                    FROM cart_cart guest_cart
                    INNER JOIN cart_cart_checkout guest_checkout ON guest_checkout.cart_cart_id = guest_cart.id
                    LEFT JOIN cart_invoice_address guest_invoice ON guest_invoice.id = guest_cart.invoice_address_id
                    LEFT JOIN cart_delivery_address guest_delivery ON guest_delivery.id = guest_cart.delivery_address_id
                    WHERE guest_cart.user_id IS NULL
                      AND (
                          guest_checkout.payed_at IS NOT NULL
                          OR guest_checkout.ordered_at IS NOT NULL
                      )
                      AND EXISTS (
                          SELECT 1
                          FROM cart_cart_item guest_item
                          WHERE guest_item.cart_cart_id = guest_cart.id
                      )
                      AND (
                          LOWER(BTRIM(guest_invoice.email)) = LOWER(BTRIM(%1$s.email))
                          OR LOWER(BTRIM(guest_delivery.email)) = LOWER(BTRIM(%1$s.email))
                      )
                )
                SQL,
            $alias,
        );
    }
}
