<?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\Components;

use Doctrine\ORM\QueryBuilder;
use Ferienpass\AdminBundle\Export\ExportQueryBuilderInterface;
use Ferienpass\AdminBundle\Form\Filter\AbstractFilter;
use Ferienpass\AdminBundle\List\ListCriteriaApplier;
use Ferienpass\AdminBundle\LiveComponent\MultiSelect;
use Ferienpass\AdminBundle\LiveComponent\MultiSelectAction;
use Ferienpass\AdminBundle\LiveComponent\MultiSelectHandlerInterface;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Pagination\Paginator;
use FOS\HttpCacheBundle\Http\SymfonyResponseTagger;
use Psr\Container\ContainerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Exception\InvalidParameterException;
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Symfony\UX\TwigComponent\Attribute\PostMount;

#[AsLiveComponent(route: 'live_component_admin')]
class SearchableQueryableList extends AbstractController
{
    use ComponentToolsTrait;
    use ComponentWithFormTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    /**
     * Top-level query params the list pages legitimately understand. Anything
     * else triggers a 404 — prevents typos / orphaned bookmarks from
     * silently rendering the unfiltered list.
     *
     *  view, sort, q       — LiveProps with UrlMapping on this component
     *  filter              — filter sidebar (sub-keys validated below)
     *  page                — pagination (read by some controllers)
     *  _*                  — Symfony / Live / Turbo internals
     *  utm_*, gclid, …     — analytics deep-links
     */
    private const array ALLOWED_QUERY_PARAMS = ['view', 'sort', 'q', 'filter', 'page'];
    private const array ALLOWED_ANALYTICS_PARAMS = ['gclid', 'fbclid', 'msclkid', 'ref'];

    #[LiveProp(writable: true, url: new UrlMapping(as: 'view'))]
    public ?string $layout = null;

    #[LiveProp]
    public array $config;

    #[LiveProp]
    public array $searchable = [];

    #[LiveProp(writable: true, url: new UrlMapping(as: 'q'))]
    public ?string $query = null;

    #[LiveProp]
    public QueryBuilder $qb;

    #[LiveProp(hydrateWith: 'hydrateMs', dehydrateWith: 'dehydrateMs')]
    public ?MultiSelect $ms = null;

    #[LiveProp(writable: true)]
    public array $selected = [];

    #[LiveProp(writable: true)]
    public bool $selectedAll = false;

    #[LiveProp]
    public ?string $routeName = null;
    #[LiveProp]
    public array $routeParameters = [];

    #[LiveProp]
    public ?string $headline = null;
    #[LiveProp]
    public ?string $createUrl = null;

    #[LiveProp]
    public ?string $viewKey = null;
    #[LiveProp]
    public ?string $viewPathPrefix = null;

    #[LiveProp]
    public array $exports = [];

    #[LiveProp(url: new UrlMapping(as: 'filter'))]
    public array $filterData = [];

    #[LiveProp]
    public ?string $viewId = null;

    #[LiveProp(writable: true, url: new UrlMapping(as: 'sort'))]
    public ?string $sorting = null;

    #[LiveProp]
    public ?string $filterType = null;

    /**
     * Optional subject entity for the page header (e.g. the participant on the
     * attendances list). Carried as a LiveProp so it survives live re-renders —
     * the page controller's context is gone by then, see the `page_header`
     * block in the host template.
     */
    #[LiveProp]
    public ?ParticipantInterface $participant = null;

    public function __construct(private readonly FormFactoryInterface $formFactory, #[TaggedLocator('ferienpass_admin.filter')] private readonly ServiceLocator $filters, #[TaggedLocator(ExportQueryBuilderInterface::class, defaultIndexMethod: 'getFormat')] private readonly ServiceLocator $exporters, private readonly RequestStack $requestStack, public readonly EventDispatcherInterface $dispatcher, private ContainerInterface $locator, #[TaggedLocator('ferienpass_admin.ms_handler')] private ServiceLocator $multiSelectHandlers, private readonly ListCriteriaApplier $applier, private readonly SymfonyResponseTagger $responseTagger)
    {
    }

    #[PostMount]
    public function postMount(): void
    {
        if (null === $this->viewId) {
            $route = $this->requestStack->getCurrentRequest()?->attributes->get('_route');
            $this->viewId = $route ? explode('.', $route)[0] : null;
        }

        $this->guardAgainstMalformedQuery();
        $this->guardAgainstUnknownQueryParams();

        $this->onPostHydrate();

        if (!$this->validateField('filter', false)) {
            // 404 when filters are not valid
        }
    }

    #[LiveAction]
    public function toggleLayout(): void
    {
        $current = $this->getEffectiveLayout();
        $new = 'table' === $current ? 'list' : 'table';
        $default = $this->isGranted('ROLE_ADMIN') ? 'table' : 'list';

        $this->layout = ($new === $default) ? null : $new;
    }

    public function getPagination(): Paginator
    {
        $this->prepare();

        if ($this->query) {
            unset($this->routeParameters['page']);
        }

        $paginator = new Paginator($this->qb, 250)->paginate((int) ($this->routeParameters['page'] ?? 1));

        $tags = [];
        foreach ($paginator->getResults() as $entity) {
            $tag = $this->tagForEntity($entity);
            if (null !== $tag) {
                $tags[] = $tag;
            }
        }
        if ([] !== $tags) {
            $this->responseTagger->addTags($tags);
        }

        return $paginator;
    }

    #[LiveListener('admin_list:changed')]
    public function changed()
    {
        // no need to do anything here: the component will re-render
    }

    public function getSortingFields(): array
    {
        return $this->getFilter()?->getSearchable() ?? [];
    }

    public function getFilters(): array
    {
        return $this->getFilter()?->getFilterable() ?? [];
    }

    public function getFiltersGrouped(): array
    {
        return $this->getFilter()?->getFilterableGrouped($this->viewId) ?? [];
    }

    #[LiveAction]
    public function filter(): void
    {
        $this->submitForm();
    }

    #[LiveAction]
    public function resetFilters(): void
    {
        $this->formValues = [];
        $this->filterData = [];
    }

    #[LiveAction]
    public function unsetFilter(#[LiveArg] string $filterName): void
    {
        unset($this->formValues[$filterName]);
    }

    #[LiveAction]
    public function paginate(#[LiveArg] int $page): void
    {
        $this->routeParameters['page'] = $page;
    }

    #[LiveAction]
    public function selectAll(): void
    {
        $allItems = iterator_to_array($this->getPagination()->getResults());
        if (\count($this->selected) < \count($allItems)) {
            if (0 === \count($this->selected) && 0 !== \count($this->ms->getPreferred())) {
                $this->selected = $this->ms->getPreferred();
                $this->selectedAll = false;
            } else {
                $this->selected = array_map(static fn ($item) => $item->getId(), $allItems);
                $this->selectedAll = true;
            }
        } else {
            $this->selected = [];
            $this->selectedAll = false;
        }
    }

    #[LiveAction]
    public function submitMultiSelect(#[LiveArg('actionName')] string $action, Request $request): Response
    {
        /** @var MultiSelectHandlerInterface $controller */
        $controller = $this->multiSelectHandlers->get($this->ms->getHandler());

        return $controller->handleMultiSelect($action, $this->selected, $request);
    }

    #[ExposeInTemplate]
    public function msButtons(): array
    {
        if (!$this->ms instanceof MultiSelect) {
            return [];
        }

        return $this->ms->getActions();
    }

    #[ExposeInTemplate]
    public function entityClass(): string
    {
        return explode(' ', (string) $this->qb->getDQLPart('from')[0], 2)[0];
    }

    #[ExposeInTemplate]
    public function entityShortName(): string
    {
        $class = $this->entityClass();

        return lcfirst(substr($class, strrpos($class, '\\') + 1));
    }

    public function dehydrateMs(?MultiSelect $ms): ?array
    {
        if (!$ms instanceof MultiSelect) {
            return null;
        }

        return [
            'actions' => array_map(static fn (MultiSelectAction $a) => [
                'name' => $a->name,
                'route' => $a->route,
                'routeParameters' => $a->routeParameters,
                'method' => $a->method,
                'turboFrame' => $a->turboFrame,
            ], $ms->getActions()),
            'handler' => $ms->getHandler(),
            'preferred' => $ms->getPreferred(),
        ];
    }

    public function hydrateMs(?array $data): ?MultiSelect
    {
        if (null === $data) {
            return null;
        }

        $actions = array_map(static fn (array $a) => new MultiSelectAction($a['name'], $a['route'] ?? null, $a['routeParameters'] ?? [], $a['method'] ?? 'POST', $a['turboFrame'] ?? null), $data['actions']);

        return new MultiSelect($actions, $data['handler'], $data['preferred']);
    }

    #[PostHydrate]
    public function onPostHydrate(): void
    {
        $defaultLayout = $this->isGranted('ROLE_ADMIN') ? 'table' : 'list';
        if ($this->layout === $defaultLayout) {
            $this->layout = null;
        }

        $defaultSort = $this->getFilter()?->getSortable()[0] ?? null;
        if (null !== $defaultSort && ($this->sorting === $defaultSort || '' === $this->sorting)) {
            $this->sorting = null;
        }

        if ('' === $this->query) {
            $this->query = null;
        }
    }

    #[ExposeInTemplate()]
    public function getEffectiveLayout(): string
    {
        return $this->layout ?? ($this->isGranted('ROLE_ADMIN') ? 'table' : 'list');
    }

    #[ExposeInTemplate]
    public function getExportUrls(): array
    {
        if (null === $this->routeName || [] === $this->exports) {
            return [];
        }

        $baseExportRoute = str_replace('_index', '_export', $this->routeName);
        $scopedExportRoute = $baseExportRoute.'_in_edition';

        $params = $this->routeParameters;

        unset($params['_route'], $params['_controller'], $params['_locale'], $params['_route_params']);

        $hasEdition = !empty($params['edition'] ?? null);

        $urls = [];
        foreach ($this->exports as $format) {
            $url = null;

            if ($hasEdition) {
                try {
                    $url = $this->generateUrl($scopedExportRoute, array_merge($params, ['format' => $format]));
                } catch (RouteNotFoundException|InvalidParameterException|MissingMandatoryParametersException) {
                    // Scoped variant not defined for this list — fall through
                    // to the bare route below, dropping the edition param.
                }
            }

            if (null === $url) {
                $bareParams = $params;
                unset($bareParams['edition']);
                try {
                    $url = $this->generateUrl($baseExportRoute, array_merge($bareParams, ['format' => $format]));
                } catch (RouteNotFoundException|InvalidParameterException|MissingMandatoryParametersException) {
                    continue;
                }
            }

            $urls[$format] = $url;
        }

        return $urls;
    }

    protected function instantiateForm(): FormInterface
    {
        if (!($filter = $this->getFilter()) instanceof AbstractFilter) {
            return $this->formFactory->create();
        }

        $filterDataFromUrl = array_filter($this->routeParameters, fn (string $key) => \in_array($key, $this->getFilters(), true), \ARRAY_FILTER_USE_KEY);

        $filterData = array_merge($this->filterData, $filterDataFromUrl);
        $filterForm = $this->formFactory->create($filter::class, null, ['view_id' => $this->viewId]);
        $filterForm->submit($filterData);

        return $this->formFactory->create($filter::class, $filterForm->getData(), ['view_id' => $this->viewId]);
    }

    /**
     * Throws 404 if the URL carries any query param outside the well-known
     * allow-list. Only runs on the initial (non-XHR) page request — Live
     * AJAX re-renders go to a different route and would never carry user
     * URL noise anyway.
     */
    /**
     * Throws 404 if the URL carries malformed UTF-8 anywhere in the query
     * string (keys or nested values). Scanners/bots routinely send invalid
     * byte sequences; those flow into LiveProps (`query`, `routeParameters`,
     * `filterData`) and later blow up when the component dehydrates its props
     * via json_encode(..., JSON_THROW_ON_ERROR) at render time. Reject early
     * so it surfaces as a handled 404 instead of an uncaught 500.
     */
    private function guardAgainstMalformedQuery(): void
    {
        $request = $this->requestStack->getMainRequest();
        if (null === $request || $request->isXmlHttpRequest()) {
            return;
        }

        if (!self::isValidUtf8($request->query->all())) {
            throw new NotFoundHttpException('Malformed query string');
        }
    }

    private static function isValidUtf8(mixed $value): bool
    {
        if (\is_array($value)) {
            foreach ($value as $key => $item) {
                if ((\is_string($key) && !mb_check_encoding($key, 'UTF-8')) || !self::isValidUtf8($item)) {
                    return false;
                }
            }

            return true;
        }

        return !\is_string($value) || mb_check_encoding($value, 'UTF-8');
    }

    private function guardAgainstUnknownQueryParams(): void
    {
        $request = $this->requestStack->getMainRequest();
        if (null === $request || $request->isXmlHttpRequest()) {
            return;
        }

        foreach ($request->query->keys() as $key) {
            if (\in_array($key, self::ALLOWED_QUERY_PARAMS, true)) {
                continue;
            }
            if (\in_array($key, self::ALLOWED_ANALYTICS_PARAMS, true)) {
                continue;
            }
            if (str_starts_with((string) $key, '_') || str_starts_with((string) $key, 'utm_')) {
                continue;
            }

            throw new NotFoundHttpException(\sprintf('Unknown query parameter: %s', $key));
        }
    }

    private function tagForEntity(object $entity): ?string
    {
        return match (true) {
            $entity instanceof OfferInterface => CacheTag::offer($entity),
            $entity instanceof Host => CacheTag::host($entity),
            $entity instanceof ParticipantInterface => CacheTag::participant($entity),
            $entity instanceof Attendance => CacheTag::attendance($entity),
            $entity instanceof User => CacheTag::user($entity),
            $entity instanceof Edition => CacheTag::edition($entity),
            default => null,
        };
    }

    private function persistFilterQuery(FormInterface $form): void
    {
        $this->filterData = [];

        foreach ($form->all() as $name => $child) {
            if ($child->isEmpty() || \in_array($name, array_keys($this->routeParameters), true)) {
                continue;
            }

            $this->filterData[$name] = $child->getViewData();
        }

        ksort($this->filterData);
    }

    private function prepare(): void
    {
        $filterDataFromUrl = array_filter($this->routeParameters, fn (string $key) => \in_array($key, $this->getFilters(), true), \ARRAY_FILTER_USE_KEY);
        $filterData = array_merge($this->formValues, $filterDataFromUrl);

        $this->applier->apply($this->qb, $this->filterType, $this->searchable, $this->query, $this->sorting, $filterData, $this->viewId, $this->config);

        if ($this->getFilter() instanceof AbstractFilter) {
            $this->persistFilterQuery($this->getForm());
        }
    }

    private function getFilter(): ?AbstractFilter
    {
        if (null === $this->filterType) {
            return null;
        }

        if (!($filter = $this->filters->get($this->filterType)) instanceof AbstractFilter) {
            return null;
        }

        return $filter;
    }
}
