<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Facade;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Entity\UserActivity;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class EraseDataFacade
{
    public function __construct(private readonly Connection $connection, private readonly ParticipantRepositoryInterface $participants, private readonly EntityManagerInterface $doctrine, private readonly SystemSettingsManager $systemSettingsManager, #[Autowire(param: 'ferienpass.model.participant.class')] private readonly string $participantEntityClass)
    {
    }

    public function eraseData(): void
    {
        // Retain participant age for statistics
        $this->retainParticipantAge();

        // Delete all participants that have attendances on events with past holiday
        $this->deleteParticipants();

        // Delete parents that have no participants and haven't logged in since a while
        $this->deleteMembersWithNoParticipants();
    }

    public function expiredParticipants(): array
    {
        $deleteAfterInterval = $this->systemSettingsManager->getEraseDataPeriod();
        if (!$deleteAfterInterval) {
            return [];
        }

        // Strictly validate the admin-settable period ("<number> <unit>", e.g. "30 days").
        // Never interpolate this value into DQL; compute the cutoff in PHP and bind it.
        if (!preg_match('/^(\d+)\s+(day|days|week|weeks|month|months|year|years)$/i', trim($deleteAfterInterval), $m)) {
            // Fail safe: an invalid period must not run an unbounded or injected query.
            throw new \UnexpectedValueException(\sprintf('Invalid erase data period "%s".', $deleteAfterInterval));
        }

        $cutoff = new \DateTimeImmutable()->modify(\sprintf('-%d %s', (int) $m[1], strtolower($m[2])));

        $qb = $this->doctrine->createQueryBuilder();

        $qb
            ->select('p.id, p.lastname')
            ->from($this->participantEntityClass, 'p')
            ->leftJoin('p.attendances', 'a')
            ->leftJoin('a.offer', 'f')
            ->groupBy('p.id')
        ;

        $orX = [
            $qb->expr()->andX(
                $qb->expr()->isNull('f'),
                $qb->expr()->orX(
                    $qb->expr()->isNull('p.createdAt'),
                    $qb->expr()->lt('p.createdAt', ':eraseCutoff')
                )
            ),
        ];

        $qb
            ->leftJoin('f.edition', 'e')->leftJoin('e.tasks', 'et')
            ->setParameter('showOffers', 'show_offers')
            ->setParameter('eraseCutoff', $cutoff)
        ;

        $orX[] = $qb->expr()->andX(
            $qb->expr()->eq('et.type', ':showOffers'),
            $qb->expr()->lt('et.periodEnd', ':eraseCutoff')
        );

        $qb
            ->where($qb->expr()->orX(...$orX))
            ->orderBy('p.lastname', 'ASC')
        ;

        $participantsToDelete = $qb->getQuery()->getScalarResult();

        $qb = $this->doctrine->createQueryBuilder();

        $qb
            ->select('p.id')
            ->from($this->participantEntityClass, 'p')
            ->innerJoin('p.attendances', 'a')
            ->innerJoin('a.offer', 'f')
            ->leftJoin('f.dates', 'd')
            ->leftJoin('p.user', 'u')
            ->groupBy('p.id')
        ;

        $orX = [
            $qb->expr()->gt('d.end', 'CURRENT_TIMESTAMP()'),
            $qb->expr()->andX(
                $qb->expr()->isNotNull('u.dontDeleteBefore'),
                $qb->expr()->gt('u.dontDeleteBefore', 'CURRENT_TIMESTAMP()')
            ),
        ];

        $qb->innerJoin('f.edition', 'e')->innerJoin('e.tasks', 'et');

        $orX[] = $qb->expr()->gt('et.periodEnd', 'CURRENT_TIMESTAMP()');

        $qb->where($qb->expr()->orX(...$orX));

        $participantsToKeep = $qb->getQuery()->getScalarResult();

        $participantsToDelete = array_column($participantsToDelete, 0);
        $participantsToKeep = array_column($participantsToKeep, 0);

        $delete = array_diff($participantsToDelete, $participantsToKeep);
        if ($delete) {
            return $this->participants->findBy(['id' => array_diff($participantsToDelete, $participantsToKeep)]);
        }

        return [];
    }

    /**
     * Pseudonymize the given participants and delete the participant records,
     * while keeping their attendances — and therefore any issued receipts —
     * intact. Each attendance keeps a random pseudonym (for statistics) but
     * loses its personal association, so the receipt line drops the child's name
     * yet retains the offer and fee. The participant is removed with a bulk query
     * so Doctrine's `cascade: remove` on the attendances collection is bypassed
     * (an ORM remove() would delete the attendances we deliberately keep).
     *
     * Shared by the scheduled data-erasure cron and the admin "delete" flow, so
     * an admin can honour an erasure request even when the child sits on an
     * immutable paid receipt.
     *
     * @param array<BaseParticipant> $participants
     */
    public function pseudonymizeParticipants(array $participants): void
    {
        if ([] === $participants) {
            return;
        }

        /** @var BaseParticipant $participant */
        foreach ($participants as $participant) {
            /** @var Attendance $attendance */
            $pseudonym = bin2hex(random_bytes(5));
            foreach ($participant->getAttendances() as $attendance) {
                // Create a pseudonym for each participant
                $attendance->setParticipantPseudonym($pseudonym);

                // Remove parent association so attendances do not get removed
                $attendance->unsetParticipant();
            }
        }

        $this->doctrine->flush();

        $this->participants
            ->createQueryBuilder('p')
            ->delete()
            ->where('p IN (:ids)')
            ->setParameter('ids', $participants)
            ->getQuery()
            ->execute()
        ;
    }

    /**
     * Pseudonymize a whole account: anonymize every child's attendances (keeping
     * them, and therefore any issued receipts) and then delete the account
     * record together with its children — erasing the personal data of both the
     * parent and the children while the receipts keep their offer and fee lines.
     *
     * Unlike {@see pseudonymizeParticipants()} this removes the account through
     * the ORM so its own associations (account-scoped activities, host member
     * associations, …) are cleaned up by cascade; each attendance is detached
     * from its participant beforehand so the `User → participants → attendances`
     * cascade: remove does not delete the attendances we deliberately keep.
     *
     * The receipt's billing-address snapshot is intentionally retained — it is
     * the invoice recipient and subject to the statutory retention obligation.
     */
    public function pseudonymizeAccount(User $account): void
    {
        foreach ($account->getParticipants() as $participant) {
            $pseudonym = bin2hex(random_bytes(5));
            foreach ($participant->getAttendances()->toArray() as $attendance) {
                $attendance->setParticipantPseudonym($pseudonym);

                // Detach from the participant so the cascade remove below keeps it.
                $participant->removeAttendance($attendance);
            }
        }

        // Activities this account created elsewhere reference it via a nullable
        // FK; remove them explicitly (mirrors AccountsController::delete).
        foreach ($this->doctrine->getRepository(UserActivity::class)->findBy(['createdBy' => $account]) as $activity) {
            $this->doctrine->remove($activity);
        }

        $this->doctrine->remove($account);
        $this->doctrine->flush();
    }

    private function deleteParticipants(): void
    {
        $this->pseudonymizeParticipants($this->expiredParticipants());
    }

    private function deleteMembersWithNoParticipants(): void
    {
        $userIds = $this->doctrine->getRepository(User::class)
            ->createQueryBuilder('u')
            ->select('u.id')
            ->leftJoin('u.participants', 'p')
            ->where('p.id IS NULL')
            // ->andWhere('u.lastLogin < DATE_SUB(NOW(), INTERVAL 2 WEEK)')
            ->andWhere("JSON_SEARCH(u.roles, 'one', :role_member) IS NOT NULL")
            ->andWhere("JSON_SEARCH(u.roles, 'one', :role_host) IS NULL")
            ->andWhere("JSON_SEARCH(u.roles, 'one', :role_admin) IS NULL")
            ->andWhere("JSON_SEARCH(u.roles, 'one', :role_sadmin) IS NULL")
            ->andWhere('u.dontDeleteBefore IS NULL OR u.dontDeleteBefore < CURRENT_TIMESTAMP()')
            ->setParameter('role_member', 'ROLE_MEMBER')
            ->setParameter('role_host', 'ROLE_HOST')
            ->setParameter('role_admin', 'ROLE_ADMIN')
            ->setParameter('role_sadmin', 'ROLE_SUPER_ADMIN')
            ->getQuery()
            ->getSingleColumnResult()
        ;

        $this->doctrine->getRepository(User::class)
            ->createQueryBuilder('u')
            ->delete()
            ->where('u.id IN (:ids)')
            ->setParameter('ids', $userIds, ArrayParameterType::INTEGER)
            ->getQuery()
            ->execute()
        ;
    }

    private function retainParticipantAge(): void
    {
        $this->connection->executeQuery(
            <<<'SQL'
                UPDATE Attendance a
                INNER JOIN Participant p ON a.participant_id = p.id
                INNER JOIN Offer f ON a.offer_id = f.id
                LEFT OUTER JOIN OfferDate d ON d.offer_id = f.id
                SET age = (IF((p.dateOfBirth IS NULL), null, TIMESTAMPDIFF(YEAR, p.dateOfBirth, d.begin)))
                WHERE a.age IS NULL
                SQL
        )->rowCount();
    }
}
