<?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\AdminBundle\Controller\Page;

use Contao\CoreBundle\Entity\WebauthnCredential;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\AdminBundle\Breadcrumb\Breadcrumb;
use Ferienpass\AdminBundle\Controller\TagsResponses;
use Ferienpass\AdminBundle\Export\ExportQueryBuilderInterface;
use Ferienpass\AdminBundle\Form\EditAccountType;
use Ferienpass\AdminBundle\Form\Filter\AccountsFilter;
use Ferienpass\AdminBundle\Form\FormScreenFactory;
use Ferienpass\AdminBundle\Form\HandleFormTrait;
use Ferienpass\AdminBundle\List\ListCriteriaApplier;
use Ferienpass\AdminBundle\Routing\Base58;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Entity\UserActivity;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Message\UnblockEmail;
use Ferienpass\CoreBundle\Repository\SentMessageRepository;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
use Symfony\UX\Turbo\TurboBundle;

#[IsGranted('ROLE_ADMIN')]
#[Route('/accounts')]
final class AccountsController extends AbstractController
{
    use HandleFormTrait;
    use TagsResponses;

    public const array ROLES = [
        'eltern' => 'ROLE_MEMBER',
        'veranstaltende' => 'ROLE_HOST',
        'admins' => 'ROLE_ADMIN',
    ];

    public function __construct(#[TaggedLocator(ExportQueryBuilderInterface::class, defaultIndexMethod: 'getFormat')] private readonly ServiceLocator $exporters)
    {
    }

    #[Route('/{role?}', name: 'admin_accounts_index', requirements: ['role' => '[a-zA-Z0-9_\-+]+'], priority: -2)]
    public function index(?string $role, UserRepository $repository, Breadcrumb $breadcrumb, EntityManagerInterface $em): Response
    {
        // The `role` path segment is either empty, a single slug, or several
        // slugs joined by "+". Empty = all account types. Single = the existing
        // contract. Multiple = filter the table over a set of roles.
        $slugs = $role ? array_values(array_filter(explode('+', $role))) : [];
        foreach ($slugs as $slug) {
            if (!\array_key_exists($slug, self::ROLES)) {
                throw $this->createNotFoundException('The role does not exist');
            }
        }
        $selectedRoles = array_values(array_map(static fn (string $s) => self::ROLES[$s], $slugs));

        // Only super admins can see admins
        if (\in_array('ROLE_ADMIN', $selectedRoles, true) && !$this->isGranted('ROLE_SUPER_ADMIN')) {
            throw $this->createAccessDeniedException();
        }

        $accountTypes = $this->visibleAccountTypes();

        $qb = $repository->createQueryBuilder('i');

        if ($selectedRoles) {
            $orExpr = $qb->expr()->orX();
            foreach ($selectedRoles as $idx => $r) {
                $orExpr->add("JSON_SEARCH(i.roles, 'one', :role_$idx) IS NOT NULL");
                $qb->setParameter("role_$idx", $r);
            }
            $qb->andWhere($orExpr);
        } elseif (!$this->isGranted('ROLE_SUPER_ADMIN')) {
            // Hide admins from non-super-admin users when no role is selected.
            $qb
                ->andWhere("JSON_SEARCH(i.roles, 'one', :role_excl_admin) IS NULL")
                ->setParameter('role_excl_admin', 'ROLE_ADMIN')
            ;
        }

        // Whenever any of the visible roles is ROLE_HOST (or no role filter is
        // active and ROLE_HOST is part of the unscoped set), provide the host
        // join so the column / search can resolve.
        $needsHostJoin = !$selectedRoles || \in_array('ROLE_HOST', $selectedRoles, true);
        if ($needsHostJoin) {
            $qb
                ->leftJoin('i.hostAssociations', 'ha')
                ->addSelect('ha')
                ->leftJoin('ha.host', 'h')
                ->addSelect('h')
            ;
        }

        // Solve N+1
        $qb
            ->leftJoin('i.blockedContact', 'bc')
            ->addSelect('bc')
            ->leftJoin('i.debtor', 'd')
            ->addSelect('d')
            ->leftJoin('i.activity', 'l')
            ->addSelect('l')
            ->leftJoin('i.participants', 'p')
            ->addSelect('p')
            ->leftJoin('p.attendances', 'a')
            ->addSelect('a')
        ;

        $searchable = ['firstname', 'lastname', 'email'];
        if ($needsHostJoin) {
            $searchable[] = 'h.name';
        }

        // The "create" CTA only makes sense when exactly one role is in scope.
        // Otherwise hide it; the user can pick a specific type first.
        $singleRoleSlug = 1 === \count($slugs) ? $slugs[0] : null;
        $createUrl = $singleRoleSlug ? $this->generateUrl('admin_accounts_create', ['role' => $singleRoleSlug]) : null;

        // Flat list of User UUIDs (RFC4122 strings) that have at least one
        // passkey enrolled. Used by the row template to render a tiny key
        // glyph next to the name; doing it as a single distinct query keeps
        // the per-row cost at O(1) `in` instead of N+1 lookups.
        $usersWithPasskey = $em->createQueryBuilder()
            ->select('DISTINCT wc.userHandle')
            ->from(WebauthnCredential::class, 'wc')
            ->getQuery()
            ->getSingleColumnResult()
        ;

        return $this->render('@FerienpassAdmin/page/accounts/index.html.twig', [
            'qb' => $qb,
            'filterType' => AccountsFilter::class,
            'exports' => array_keys($this->exporters->getProvidedServices()),
            'role' => 1 === \count($selectedRoles) ? $selectedRoles[0] : null,
            'selectedRoles' => $selectedRoles,
            'accountTypes' => $accountTypes,
            'searchable' => $searchable,
            'createUrl' => $createUrl,
            'usersWithPasskey' => $usersWithPasskey,
            'headline' => 'accounts.title',
            'breadcrumb' => $breadcrumb->generate(
                ['accounts.title', ['route' => 'admin_accounts_index']],
                1 === \count($selectedRoles) ? 'accounts.'.$selectedRoles[0] : null,
            ),
        ]);
    }

    #[Route('/export.{format}/{role?}', name: 'admin_accounts_export', requirements: ['role' => '[a-zA-Z0-9_\-+]+', 'format' => '\w+'])]
    public function export(?string $role, UserRepository $repository, string $format, Request $request, ListCriteriaApplier $filterer)
    {
        if (!$this->exporters->has($format)) {
            throw $this->createNotFoundException();
        }

        // Mirror the index: empty `role` means "all account types", a single
        // slug means one type, and several slugs joined by "+" mean filter
        // across that set.
        $slugs = $role ? array_values(array_filter(explode('+', $role))) : [];
        foreach ($slugs as $slug) {
            if (!\array_key_exists($slug, self::ROLES)) {
                throw $this->createNotFoundException('The role does not exist');
            }
        }
        $selectedRoles = array_values(array_map(static fn (string $s) => self::ROLES[$s], $slugs));

        // Only super admins can see admins.
        if (\in_array('ROLE_ADMIN', $selectedRoles, true) && !$this->isGranted('ROLE_SUPER_ADMIN')) {
            throw $this->createAccessDeniedException();
        }

        $qb = $repository->createQueryBuilder('i');

        if ($selectedRoles) {
            $orExpr = $qb->expr()->orX();
            foreach ($selectedRoles as $idx => $r) {
                $orExpr->add("JSON_SEARCH(i.roles, 'one', :role_$idx) IS NOT NULL");
                $qb->setParameter("role_$idx", $r);
            }
            $qb->andWhere($orExpr);
        } elseif (!$this->isGranted('ROLE_SUPER_ADMIN')) {
            $qb
                ->andWhere("JSON_SEARCH(i.roles, 'one', :role_excl_admin) IS NULL")
                ->setParameter('role_excl_admin', 'ROLE_ADMIN')
            ;
        }

        $filterer->apply(
            $qb,
            AccountsFilter::class,
            ['firstname', 'lastname', 'email'],
            $request->query->get('q'),
            $request->query->get('sort'),
            (array) $request->query->all('filter')
        );

        $exporter = $this->exporters->get($format);

        $suffix = $role ?: 'alle';

        return $this->file($exporter->generate($qb), "accounts-$suffix.$format");
    }

    #[Route('/{uuid}', name: 'admin_accounts_show', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}', name: 'admin_accounts_show_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function show(Uuid $uuid, #[MapQueryParameter(name: 'kommentare')] ?bool $commentsOnly, Breadcrumb $breadcrumb, SentMessageRepository $messages, UserRepository $repository): Response
    {
        $user = $repository->findOneBy(['uuid' => $uuid]);
        if (null === $user) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('view', $user);

        // Tag with the user's UUID so the CacheInvalidationListener cascade
        // (User update, HostMemberAssociation persist/remove, …) actually
        // purges this page. Without it, the show page kept serving the
        // pre-edit snapshot until s-maxage expired (1 year). The broad BAN
        // only fires on User create / delete, not on updates.
        $this->tagResponse(CacheTag::user($user));

        return $this->render('@FerienpassAdmin/page/accounts/show.html.twig', [
            'item' => $user,
            'messages' => $messages->findByUser($user),
            'commentsOnly' => (bool) $commentsOnly,
            'breadcrumb' => $breadcrumb->generate(['accounts.title', ['route' => 'admin_accounts_index'], $user->getName()]),
        ]);
    }

    #[Route('/{role}/neu/{section?default}', name: 'admin_accounts_create', requirements: ['role' => '\w+'])]
    #[Route('/{uuid}/bearbeiten/{section?default}', name: 'admin_accounts_edit', requirements: ['uuid' => Base58::UUID], priority: -1)]
    #[Route('/{uuid}/bearbeiten/{section?default}', name: 'admin_accounts_edit_legacy', requirements: ['uuid' => Requirement::UUID], priority: -1)]
    public function edit(?Uuid $uuid, ?string $role, string $section, Request $request, EntityManagerInterface $entityManager, EventDispatcherInterface $dispatcher, Breadcrumb $breadcrumb, FormScreenFactory $forms, UserRepository $repository): Response
    {
        $user = null !== $uuid ? $repository->findOneBy(['uuid' => $uuid]) : null;
        if (null !== $uuid && null === $user) {
            throw $this->createNotFoundException();
        }

        if ($user instanceof User) {
            $this->denyAccessUnlessGranted('edit', $user);
        }

        if (!$user instanceof User && $role && isset(self::ROLES[$role])) {
            $roleName = self::ROLES[$role];
            $user = new User();
            $user->setRoles([$roleName]);
        }

        $role ??= array_find_key(
            self::ROLES,
            static fn ($r) => $r === ($user->getAccountRoles()[0] ?? null),
        );
        $roleName ??= self::ROLES[$role] ?? null;

        $form = $this->createForm(EditAccountType::class, $user, $formOptions = [
            'display_section' => $section,
        ]);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $isNew = null === $user->getId();

            $this->handleSubmit($form, $dispatcher, $request);

            $entityManager->flush();

            $this->addFlash('success', 'Die Änderungen wurden erfolgreich übernommen.');

            if ($isNew) {
                return $this->redirectToRoute('admin_accounts_password', ['uuid' => $user->getUuid()->toBase58()]);
            }

            if ($nextSection = $request->request->get('nextSection')) {
                return $this->redirectToRoute('admin_accounts_edit', ['uuid' => $user->getUuid()->toBase58(), 'section' => $nextSection]);
            }

            if ($redirect = $request->request->get('redirect')) {
                return $this->redirectToRoute($redirect, ['uuid' => $user->getUuid()->toBase58()]);
            }

            // Stay on the edit page after Speichern so the user can keep
            // editing the same record (preserves the current section).
            // Use `nextSection` or `redirect` form fields if you want to
            // jump elsewhere.
            return $this->redirectToRoute('admin_accounts_edit', [
                'uuid' => $user->getUuid()->toBase58(),
                'section' => $section,
            ]);
        }

        $breadcrumbTitle = $user->getId() ? \sprintf('%s (bearbeiten)', $user->getName()) : 'accounts.new';

        $breadcrumb = $breadcrumb->generate(
            ['accounts.title', ['route' => 'admin_accounts_index', 'routeParameters' => array_filter(['role' => $role])]],
            $role ? ['accounts.'.$roleName, ['route' => 'admin_accounts_index', 'routeParameters' => ['role' => $role]]] : null,
            $breadcrumbTitle
        );

        $this->tagResponse(CacheTag::user($user));

        return $this->render('@FerienpassAdmin/page/accounts/edit.html.twig', [
            'item' => $user,
            'form' => $form,
            'formOptions' => $formOptions,
            'formScreen' => $forms->screen(EditAccountType::class, $user),
            'section' => $section,
            'headline' => $user->getId() ? $user->getName() : 'accounts.new',
            'breadcrumb' => $breadcrumb,
        ]);
    }

    #[Route('/{uuid}/löschen', name: 'admin_accounts_delete', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/löschen', name: 'admin_accounts_delete_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function delete(Uuid $uuid, Request $request, EntityManagerInterface $entityManager, UserRepository $repository): Response
    {
        $item = $repository->findOneBy(['uuid' => $uuid]);
        if (null === $item) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('delete', $item);

        $form = $this->createForm(FormType::class, options: [
            'action' => $this->generateUrl('admin_accounts_delete', ['uuid' => $item->getUuid()->toBase58()]),
        ]);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $createdByActivities = $entityManager->getRepository(UserActivity::class)->findBy(['createdBy' => $item]);
            foreach ($createdByActivities as $activity) {
                $entityManager->remove($activity);
            }

            foreach ($item->getParticipants() as $participant) {
                /** @var $attendance */
                foreach ($participant->getAttendances() as $attendance) {
                    foreach ($attendance->getPaymentItems() as $paymentItem) {
                        $paymentItem->removeAttendanceAssociation();
                    }
                }
            }

            $deletedId = $item->getId();

            $entityManager->remove($item);
            $entityManager->flush();

            if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
                // If the request comes from Turbo, set the content type as text/vnd.turbo-stream.html and only send the HTML to update
                $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

                return $this->renderBlock('@FerienpassAdmin/page/accounts/index.html.twig', 'deleted_stream', ['id' => $deletedId]);
            }

            return $this->redirectToRoute('admin_accounts_index');
        }

        $this->tagResponse(CacheTag::user($item));

        return $this->render('@FerienpassAdmin/page/accounts/delete.html.twig', [
            'item' => $item,
            'form' => $form,
        ]);
    }

    #[Route('/{uuid}/passwort', name: 'admin_accounts_password', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/passwort', name: 'admin_accounts_password_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function password(Uuid $uuid, Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, UserRepository $repository): Response
    {
        $item = $repository->findOneBy(['uuid' => $uuid]);
        if (null === $item) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('password', $item);

        $session = $request->getSession();
        $form = $this->createForm(FormType::class, options: [
            'action' => $this->generateUrl('admin_accounts_password', ['uuid' => $item->getUuid()->toBase58()]),
        ]);

        if ($session->has('account_password_tmp')) {
            $password = $session->get('account_password_tmp');
            $session->remove('account_password_tmp');

            $this->tagResponse(CacheTag::user($item));

            return $this->render('@FerienpassAdmin/page/accounts/password.html.twig', [
                'item' => $item,
                'password' => $password,
            ]);
        }

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $password = $this->generatePassword();
            $encodedPassword = $passwordHasher->hashPassword($item, $password);

            $session->set('account_password_tmp', $password);
            $item->setPassword($encodedPassword);
            $item->setModifiedAt();

            $entityManager->flush();

            return $this->redirectToRoute('admin_accounts_password', ['uuid' => $item->getUuid()->toBase58()]);
        }

        $this->tagResponse(CacheTag::user($item));

        return $this->render('@FerienpassAdmin/page/accounts/password.html.twig', [
            'item' => $item,
            'form' => $form,
        ]);
    }

    #[Route('/{uuid}/email-freischalten', name: 'admin_accounts_unblock', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/email-freischalten', name: 'admin_accounts_unblock_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function unblock(Uuid $uuid, Request $request, MessageBusInterface $commandBus, UserRepository $repository): Response
    {
        $item = $repository->findOneBy(['uuid' => $uuid]);
        if (null === $item) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('unblock', $item);

        if (!$item->isBlockedEmail()) {
            $this->tagResponse(CacheTag::user($item));

            return $this->render('@FerienpassAdmin/page/accounts/unblock.html.twig', [
                'item' => $item,
            ]);
        }

        $form = $this->createForm(FormType::class, options: [
            'action' => $this->generateUrl('admin_accounts_unblock', ['uuid' => $item->getUuid()->toBase58()]),
        ]);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $commandBus->dispatch(new UnblockEmail($item->getEmail(), (string) $item->getUuid()));

            return $this->redirectToRoute('admin_accounts_unblock', ['uuid' => $item->getUuid()->toBase58()]);
        }

        return $this->render('@FerienpassAdmin/page/accounts/unblock.html.twig', [
            'item' => $item,
            'form' => $form,
        ]);
    }

    /**
     * The list of account types ("roles") visible in the AccountTypeSwitcher.
     * Each entry is a plain DTO array: { alias, name, role } so the Twig
     * component can stay strictly typed and dehydration-friendly.
     *
     * @return list<array{alias: string, name: string, role: string}>
     */
    private function visibleAccountTypes(): array
    {
        $types = [];
        foreach (self::ROLES as $slug => $roleName) {
            if ('ROLE_ADMIN' === $roleName && !$this->isGranted('ROLE_SUPER_ADMIN')) {
                continue;
            }
            $types[] = [
                'alias' => $slug,
                'role' => $roleName,
                'name' => match ($roleName) {
                    'ROLE_MEMBER' => 'Eltern',
                    'ROLE_HOST' => 'Veranstaltende',
                    'ROLE_ADMIN' => 'Admins',
                    default => $roleName,
                },
            ];
        }

        return $types;
    }

    private function generatePassword(): string
    {
        $password = '';
        $keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

        $max = mb_strlen($keyspace, '8bit') - 1;
        for ($i = 0; $i < 12; ++$i) {
            $password .= $keyspace[random_int(0, $max)];
        }

        return $password;
    }
}
