<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\ActivityBundle\Repository;

use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityPresence;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Security;

final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface
{
    public function __construct(
        private AuthorizationHelperForCurrentUserInterface $authorizationHelper,
        private CenterResolverManagerInterface $centerResolverManager,
        private ActivityRepository $repository,
        private EntityManagerInterface $em,
        private Security $security,
        private RequestStack $requestStack,
    ) {}

    /**
     * @throws NonUniqueResultException
     * @throws NoResultException
     */
    public function countByAccompanyingPeriod(AccompanyingPeriod $period, string $role, array $filters = []): int
    {
        $qb = $this->buildBaseQuery($filters);

        $qb
            ->select('COUNT(a)')
            ->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period);

        return $qb->getQuery()->getSingleScalarResult();
    }

    public function countByPerson(Person $person, string $role, array $filters = []): int
    {
        $qb = $this->buildBaseQuery($filters);

        $qb = $this->filterBaseQueryByPerson($qb, $person, $role);

        $qb->select('COUNT(a)');

        return $qb->getQuery()->getSingleScalarResult();
    }

    public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array
    {
        $qb = $this->buildBaseQuery($filters);

        $qb->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period);

        foreach ($orderBy as $field => $order) {
            $qb->addOrderBy('a.'.$field, $order);
        }

        if (null !== $start) {
            $qb->setFirstResult($start);
        }
        if (null !== $limit) {
            $qb->setMaxResults($limit);
        }

        return $qb->getQuery()->getResult();
    }

    public function buildBaseQuery(array $filters): QueryBuilder
    {
        $qb = $this->repository
            ->createQueryBuilder('a')
        ;

        if (($filters['my_activities'] ?? false) and ($user = $this->security->getUser()) instanceof User) {
            $qb->andWhere(
                $qb->expr()->orX(
                    'a.createdBy = :user',
                    'a.user = :user',
                    ':user MEMBER OF a.users'
                )
            )->setParameter('user', $user);
        }

        if ([] !== ($types = $filters['types'] ?? [])) {
            $qb->andWhere('a.activityType IN (:types)')->setParameter('types', $types);
        }

        if ([] !== ($jobs = $filters['jobs'] ?? [])) {
            $qb
                ->leftJoin('a.createdBy', 'creator')
                ->leftJoin('a.user', 'activity_u')
                ->andWhere(
                    $qb->expr()->orX(
                        $qb->expr()->exists(
                            sprintf(
                                'SELECT 1 FROM %s ujh_creator WHERE ujh_creator.user = a.createdBy '
                                .'AND ujh_creator.job IN (:jobs) AND a.createdAt > ujh_creator.startDate '
                                .'AND (ujh_creator.endDate IS NULL or ujh_creator.endDate > a.date)',
                                User\UserJobHistory::class
                            )
                        ),
                        $qb->expr()->exists(
                            sprintf(
                                'SELECT 1 FROM %s ujh_u WHERE ujh_u.user = a.user '
                                .'AND ujh_u.job IN (:jobs) AND a.createdAt > ujh_u.startDate '
                                .'AND (ujh_u.endDate IS NULL or ujh_u.endDate > a.date)',
                                User\UserJobHistory::class
                            )
                        ),
                        $qb->expr()->exists(
                            sprintf(
                                'SELECT 1 FROM %s ujh_users WHERE ujh_users.user MEMBER OF a.users '
                                .'AND ujh_users.job IN (:jobs) AND a.createdAt > ujh_users.startDate '
                                .'AND (ujh_users.endDate IS NULL or ujh_users.endDate > a.date)',
                                User\UserJobHistory::class
                            )
                        ),
                    )
                )
                ->setParameter('jobs', $jobs);
        }

        if (null !== ($after = $filters['after'] ?? null)) {
            $qb->andWhere('a.date >= :after')->setParameter('after', $after);
        }

        if (null !== ($before = $filters['before'] ?? null)) {
            $qb->andWhere('a.date <= :before')->setParameter('before', $before);
        }

        return $qb;
    }

    /**
     * @return array<ActivityType>
     */
    public function findActivityTypeByAssociated(AccompanyingPeriod|Person $associated): array
    {
        $in = $this->em->createQueryBuilder();
        $in
            ->select('1')
            ->from(Activity::class, 'a');

        if ($associated instanceof Person) {
            $in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE);
        } else {
            $in->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $associated);
        }

        // join between the embedded exist query and the main query
        $in->andWhere('a.activityType = t');

        $qb = $this->em->createQueryBuilder()->setParameters($in->getParameters());
        $qb
            ->select('t')
            ->from(ActivityType::class, 't')
            ->where(
                $qb->expr()->exists($in->getDQL())
            );

        return $qb->getQuery()->getResult();
    }

    public function findUserJobByAssociated(AccompanyingPeriod|Person $associated): array
    {
        $in = $this->em->createQueryBuilder();
        $in->select('IDENTITY(u.job)')
            ->distinct()
            ->from(User\UserJobHistory::class, 'u')
            ->join(
                Activity::class,
                'a',
                Join::WITH,
                'a.createdBy = u.user OR a.user = u.user OR u.user MEMBER OF a.users AND a.date >= u.startDate ANd (u.endDate IS NULL or u.endDate > a.date)'
            );

        if ($associated instanceof Person) {
            $in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE);
        } else {
            $in->andWhere('a.accompanyingPeriod = :associated');
            $in->setParameter('associated', $associated);
        }

        $qb = $this->em->createQueryBuilder()->setParameters($in->getParameters());

        $qb->select('ub', 'JSON_EXTRACT(ub.label, :lang) AS HIDDEN lang')
            ->from(UserJob::class, 'ub')
            ->where($qb->expr()->in('ub.id', $in->getDQL()))
            ->setParameter('lang', $this->requestStack->getCurrentRequest()->getLocale())
            ->orderBy('lang')
        ;

        return $qb->getQuery()->getResult();
    }

    public function findByAccompanyingPeriodSimplified(AccompanyingPeriod $period, ?int $limit = 1000): array
    {
        $rsm = new ResultSetMappingBuilder($this->em);

        $sql = '
        SELECT
            a.id AS activity_id,
            date,
            CASE WHEN durationtime IS NOT NULL THEN (EXTRACT(EPOCH from durationtime) / 60)::int ELSE 0 END AS durationtimeminute,
            attendee_id,
            comment_comment,
            emergency,
            sentreceived,
            CASE WHEN traveltime IS NOT NULL THEN (EXTRACT(EPOCH from traveltime) / 60)::int ELSE 0 END AS traveltimeminute,
            t.id AS type_id, t.name as type_name,
            p.id AS presence_id, p.name AS presence_name,
            location.id AS location_id, location.address_id, location.name AS location_name, location.phonenumber1, location.phonenumber2, location.email,
            location.locationtype_id, locationtype.title AS locationtype_title,
            users.userids AS userids,
            thirdparties.thirdpartyids,
            persons.personids,
            actions.socialactionids,
            issues.socialissueids,
            a.user_id

        FROM activity a
        LEFT JOIN chill_main_location location ON a.location_id = location.id
        LEFT JOIN chill_main_location_type locationtype ON location.locationtype_id = locationtype.id
        LEFT JOIN activitytpresence p ON a.attendee_id = p.id
        LEFT JOIN activitytype t ON a.type_id = t.id
        LEFT JOIN LATERAL (SELECT jsonb_agg(user_id) userids, activity_id FROM activity_user AS au WHERE a.id = au.activity_id GROUP BY activity_id) AS users ON TRUE
        LEFT JOIN LATERAL (SELECT jsonb_agg(thirdparty_id) thirdpartyids, activity_id FROM activity_thirdparty AS au WHERE a.id = au.activity_id GROUP BY activity_id) AS thirdparties ON TRUE
        LEFT JOIN LATERAL (SELECT jsonb_agg(person_id) personids, activity_id FROM activity_person AS au WHERE a.id = au.activity_id GROUP BY activity_id) AS persons ON TRUE
        LEFT JOIN LATERAL (SELECT jsonb_agg(socialaction_id) socialactionids, activity_id FROM chill_activity_activity_chill_person_socialaction AS au WHERE a.id = au.activity_id GROUP BY activity_id) AS actions ON TRUE
        LEFT JOIN LATERAL (SELECT jsonb_agg(socialissue_id) socialissueids, activity_id FROM chill_activity_activity_chill_person_socialissue AS au WHERE a.id = au.activity_id GROUP BY activity_id) AS issues ON TRUE

        WHERE accompanyingperiod_id = ?
        ORDER BY a.date DESC, a.id DESC
        LIMIT ?
        ';

        $rsm
            ->addEntityResult(Activity::class, 'a')
            ->addFieldResult('a', 'activity_id', 'id')
            ->addFieldResult('a', 'date', 'date')
            ->addFieldResult('a', 'comment', 'comment')
            ->addFieldResult('a', 'sentreceived', 'sentReceived')
            ->addFieldResult('a', 'emergency', 'emergency')
            ->addJoinedEntityResult(Location::class, 'location', 'a', 'location')
            ->addFieldResult('location', 'location_id', 'id')
            ->addFieldResult('location', 'location_name', 'name')
            ->addFieldResult('location', 'phonenumber1', 'phonenumber1')
            ->addFieldResult('location', 'phonenumber2', 'phonenumber2')
            ->addFieldResult('location', 'email', 'email')
            ->addJoinedEntityResult(LocationType::class, 'locationType', 'location', 'locationType')
            ->addFieldResult('locationType', 'locationtype_id', 'id')
            ->addFieldResult('locationType', 'locationtype_title', 'title')
            ->addJoinedEntityResult(ActivityType::class, 'activityType', 'a', 'activityType')
            ->addFieldResult('activityType', 'type_id', 'id')
            ->addFieldResult('activityType', 'type_name', 'name')
            ->addJoinedEntityResult(ActivityPresence::class, 'activityPresence', 'a', 'attendee')
            ->addFieldResult('activityPresence', 'presence_id', 'id')
            ->addFieldResult('activityPresence', 'presence_name', 'name')
            ->addScalarResult('user_id', 'userId', Types::INTEGER)

            // results which cannot be mapped into entity
            ->addScalarResult('comment_comment', 'comment', Types::TEXT)
            ->addScalarResult('userids', 'userIds', Types::JSON)
            ->addScalarResult('thirdpartyids', 'thirdPartyIds', Types::JSON)
            ->addScalarResult('personids', 'personIds', Types::JSON)
            ->addScalarResult('socialactionids', 'socialActionIds', Types::JSON)
            ->addScalarResult('socialissueids', 'socialIssueIds', Types::JSON)
            ->addScalarResult('durationtimeminute', 'durationTimeMinute', Types::INTEGER)
            ->addScalarResult('traveltimeminute', 'travelTimeMinute', Types::INTEGER);

        $nq = $this->em->createNativeQuery($sql, $rsm);

        $nq->setParameter(0, $period->getId())->setParameter(1, $limit);

        return $nq->getResult(AbstractQuery::HYDRATE_ARRAY);
    }

    public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): array
    {
        $qb = $this->buildBaseQuery($filters);

        $qb = $this->filterBaseQueryByPerson($qb, $person, $role);

        foreach ($orderBy as $field => $direction) {
            $qb->addOrderBy('a.'.$field, $direction);
        }

        if (null !== $start) {
            $qb->setFirstResult($start);
        }
        if (null !== $limit) {
            $qb->setMaxResults($limit);
        }

        return $qb->getQuery()->getResult();
    }

    private function filterBaseQueryByPerson(QueryBuilder $qb, Person $person, string $role): QueryBuilder
    {
        $orX = $qb->expr()->orX();
        $counter = 0;
        foreach ($this->centerResolverManager->resolveCenters($person) as $center) {
            $scopes = $this->authorizationHelper->getReachableScopes($role, $center);

            if ([] === $scopes) {
                continue;
            }

            $orX->add(sprintf('a.person = :person AND a.scope IN (:scopes_%d)', $counter));
            $qb->setParameter(sprintf('scopes_%d', $counter), $scopes);
            $qb->setParameter('person', $person);
            ++$counter;
        }

        foreach ($person->getAccompanyingPeriodParticipations() as $participation) {
            if (!$this->security->isGranted(ActivityVoter::SEE, $participation->getAccompanyingPeriod())) {
                continue;
            }

            $and = $qb->expr()->andX(
                sprintf('a.accompanyingPeriod = :period_%d', $counter),
                sprintf('a.date >= :participation_start_%d', $counter)
            );

            $qb
                ->setParameter(sprintf('period_%d', $counter), $participation->getAccompanyingPeriod())
                ->setParameter(sprintf('participation_start_%d', $counter), $participation->getStartDate());

            if (null !== $participation->getEndDate()) {
                $and->add(sprintf('a.date < :participation_end_%d', $counter));
                $qb
                    ->setParameter(sprintf('participation_end_%d', $counter), $participation->getEndDate());
            }
            $orX->add($and);
            ++$counter;
        }

        if (0 === $orX->count()) {
            $qb->andWhere('FALSE = TRUE');
        } else {
            $qb->andWhere($orX);
        }

        return $qb;
    }

    public function queryTimelineIndexer(string $context, array $args = []): array
    {
        $metadataActivity = $this->em->getClassMetadata(Activity::class);

        $from = $this->getFromClauseCenter($args);
        [$where, $parameters] = $this->getWhereClause($context, $args);

        return [
            'id' => $metadataActivity->getTableName()
                 .'.'.$metadataActivity->getColumnName('id'),
            'type' => 'activity',
            'date' => $metadataActivity->getTableName()
                 .'.'.$metadataActivity->getColumnName('date'),
            'FROM' => $from,
            'WHERE' => $where,
            'parameters' => $parameters,
        ];
    }

    private function getFromClauseCenter(array $args): string
    {
        $metadataActivity = $this->em->getClassMetadata(Activity::class);
        $metadataPerson = $this->em->getClassMetadata(Person::class);
        $associationMapping = $metadataActivity->getAssociationMapping('person');

        return $metadataActivity->getTableName().' JOIN '
            .$metadataPerson->getTableName().' ON '
            .$metadataPerson->getTableName().'.'.
                $associationMapping['joinColumns'][0]['referencedColumnName']
            .' = '
            .$associationMapping['joinColumns'][0]['name'];
    }

    private function getWhereClause(string $context, array $args): array
    {
        $where = '';
        $parameters = [];

        $metadataActivity = $this->em->getClassMetadata(Activity::class);
        $metadataPerson = $this->em->getClassMetadata(Person::class);
        $activityToPerson = $metadataActivity->getAssociationMapping('person')['joinColumns'][0]['name'];
        $activityToScope = $metadataActivity->getAssociationMapping('scope')['joinColumns'][0]['name'];
        $personToCenter = $metadataPerson->getAssociationMapping('center')['joinColumns'][0]['name'];

        // acls:
        $reachableCenters = $this->authorizationHelper->getReachableCenters(
            ActivityVoter::SEE
        );

        if (0 === \count($reachableCenters)) {
            // insert a dummy condition
            return 'FALSE = TRUE';
        }

        if ('person' === $context) {
            // we start with activities having the person_id linked to person
            $where .= sprintf('%s = ? AND ', $activityToPerson);
            $parameters[] = $args['context']->getId();
        }

        // we add acl (reachable center and scopes)
        $where .= '('; // first loop for the for centers
        $centersI = 0; // like centers#i

        foreach ($reachableCenters as $center) {
            // we pass if not in centers
            if (!\in_array($center, $args['centers'], true)) {
                continue;
            }
            // we get all the reachable scopes for this center
            $reachableScopes = $this->authorizationHelper->getReachableScopes(ActivityVoter::SEE, $center);
            // we get the ids for those scopes
            $reachablesScopesId = array_map(
                static fn (Scope $scope) => $scope->getId(),
                $reachableScopes
            );

            // if not the first center
            if (0 < $centersI) {
                $where .= ') OR (';
            }

            // condition for the center
            $where .= sprintf(' %s.%s = ? ', $metadataPerson->getTableName(), $personToCenter);
            $parameters[] = $center->getId();

            // begin loop for scopes
            $where .= ' AND (';
            $scopesI = 0; // like scope#i

            foreach ($reachablesScopesId as $scopeId) {
                if (0 < $scopesI) {
                    $where .= ' OR ';
                }
                $where .= sprintf(' %s.%s = ? ', $metadataActivity->getTableName(), $activityToScope);
                $parameters[] = $scopeId;
                ++$scopesI;
            }
            // close loop for scopes
            $where .= ') ';
            ++$centersI;
        }
        // close loop for centers
        $where .= ')';

        return [$where, $parameters];
    }
}
