<?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\PersonBundle\Entity\Household;

use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\HasCentersInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['household' => Household::class])]
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_household')]
#[MaxHolder(groups: ['household_memberships'])]
class Household implements HasCentersInterface
{
    /**
     * Addresses.
     *
     * @var Collection<int, Address>
     */
    #[Serializer\Groups(['write'])]
    #[ORM\ManyToMany(targetEntity: Address::class, cascade: ['persist', 'remove', 'merge', 'detach'])]
    #[ORM\JoinTable(name: 'chill_person_household_to_addresses')]
    #[ORM\OrderBy(['validFrom' => Criteria::DESC, 'id' => 'DESC'])]
    private Collection $addresses;

    #[ORM\Embedded(class: CommentEmbeddable::class, columnPrefix: 'comment_members_')]
    private CommentEmbeddable $commentMembers;

    /**
     * @var Collection<int, HouseholdComposition>&Selectable
     */
    #[Assert\Valid(groups: ['household_composition'], traverse: true)]
    #[ORM\OneToMany(mappedBy: 'household', targetEntity: HouseholdComposition::class, cascade: ['persist'], orphanRemoval: true)]
    #[ORM\OrderBy(['startDate' => Criteria::DESC])]
    private Collection&Selectable $compositions;

    #[Serializer\Groups(['read', 'docgen:read'])]
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: Types::INTEGER)]
    private ?int $id = null;

    /**
     * @var Collection<int, HouseholdMember>
     */
    #[Serializer\Groups(['read', 'docgen:read'])]
    #[ORM\OneToMany(mappedBy: 'household', targetEntity: HouseholdMember::class)]
    private Collection $members;

    #[Serializer\Groups(['docgen:read'])]
    #[ORM\Column(name: 'waiting_for_birth', type: Types::BOOLEAN, options: ['default' => false])]
    #[Assert\Type('boolean', groups: ['household_metadata'])]
    private bool $waitingForBirth = false;

    #[Serializer\Groups(['docgen:read'])]
    #[ORM\Column(type: Types::DATE_IMMUTABLE, name: 'waiting_for_birth_date', nullable: true, options: ['default' => null])]
    #[Assert\Expression('this.getWaitingForBirth() == false or value != null', message: 'The waiting for birth date must be set', groups: ['household_metadata'])]
    private ?\DateTimeImmutable $waitingForBirthDate = null;

    public function __construct()
    {
        $this->addresses = new ArrayCollection();
        $this->members = new ArrayCollection();
        $this->commentMembers = new CommentEmbeddable();
        $this->compositions = new ArrayCollection();
    }

    public function addAddress(Address $address): self
    {
        if (!$this->addresses->contains($address)) {
            $this->addresses[] = $address;
            $this->makeAddressConsistent();
        }

        return $this;
    }

    public function addComposition(HouseholdComposition $composition): self
    {
        if (!$this->compositions->contains($composition)) {
            $composition->setHousehold($this);
            $this->compositions[] = $composition;
        }

        $this->householdCompositionConsistency();

        return $this;
    }

    public function addMember(HouseholdMember $member): self
    {
        if (!$this->members->contains($member)) {
            $this->members[] = $member;
            $member->setHousehold($this);
        }

        return $this;
    }

    /**
     * By default, the addresses are ordered by date, descending (the most
     * recent first).
     *
     * @return Collection<Address>
     */
    public function getAddresses(): Collection
    {
        return $this->addresses;
    }

    /**
     * @return array|Address[]
     */
    public function getAddressesOrdered(): array
    {
        $addresses = $this->getAddresses()->toArray();
        usort($addresses, static function (Address $a, Address $b) {
            $validFromA = $a->getValidFrom()->format('Y-m-d');
            $validFromB = $b->getValidFrom()->format('Y-m-d');

            if ($a === $b) {
                if (null === $a->getId()) {
                    return 1;
                }

                if (null === $b->getId()) {
                    return -1;
                }

                return $a->getId() <=> $b->getId();
            }

            return $validFromA <=> $validFromB;
        });

        return $addresses;
    }

    public function getCenters(): ?iterable
    {
        $centers = [];

        foreach ($this->getCurrentPersons() as $person) {
            if (null !== $center = $person->getCenter()) {
                $centers[$center->getId() ?? spl_object_hash($center)] = $center;
            }
        }

        return array_values($centers);
    }

    public function getCommentMembers(): CommentEmbeddable
    {
        return $this->commentMembers;
    }

    /**
     * @return ArrayCollection|Collection|HouseholdComposition[]
     */
    public function getCompositions(): Collection
    {
        return $this->compositions;
    }

    #[Serializer\Groups(['read', 'docgen:read'])]
    #[Serializer\SerializedName('current_address')]
    public function getCurrentAddress(?\DateTime $at = null): ?Address
    {
        $at ??= new \DateTime('today');

        $addrs = $this->getAddresses()->filter(static fn (Address $a) => $a->getValidFrom() <= $at && (
            null === $a->getValidTo() || $a->getValidTo() > $at
        ));

        if ($addrs->count() > 0) {
            return $addrs->first();
        }

        return null;
    }

    #[Serializer\Groups(['docgen:read'])]
    #[Serializer\SerializedName('current_composition')]
    public function getCurrentComposition(?\DateTimeImmutable $at = null): ?HouseholdComposition
    {
        $at ??= new \DateTimeImmutable('today');
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where(
            $expr->andX(
                $expr->orX(
                    $expr->isNull('endDate'),
                    $expr->gt('endDate', $at)
                ),
                $expr->lte('startDate', $at)
            )
        );

        $compositions = $this->compositions->matching($criteria);

        if ($compositions->count() > 0) {
            return $compositions->first();
        }

        return null;
    }

    #[Serializer\Groups(['docgen:read'])]
    public function getCurrentMembers(?\DateTimeImmutable $now = null): Collection
    {
        return $this->getMembers()->matching($this->buildCriteriaCurrentMembers($now));
    }

    public function getCurrentMembersByPosition(Position $position, ?\DateTimeInterface $now = null)
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where($expr->eq('position', $position));

        return $this->getCurrentMembers($now)->matching($criteria);
    }

    /**
     * get current members ids.
     *
     * Used in serialization
     */
    #[Serializer\Groups(['read'])]
    #[Serializer\SerializedName('current_members_id')]
    public function getCurrentMembersIds(?\DateTimeImmutable $now = null): ReadableCollection
    {
        return $this->getCurrentMembers($now)->map(
            static fn (HouseholdMember $m) => $m->getId()
        );
    }

    /**
     * @return HouseholdMember[]
     */
    public function getCurrentMembersOrdered(?\DateTimeImmutable $now = null): Collection
    {
        $members = $this->getCurrentMembers($now);

        $members->getIterator()
            ->uasort(
                static function (HouseholdMember $a, HouseholdMember $b) {
                    if (null === $a->getPosition()) {
                        if (null === $b->getPosition()) {
                            return 0;
                        }

                        return -1;
                    }

                    if (null === $b->getPosition()) {
                        return 1;
                    }

                    if ($a->getPosition()->getOrdering() < $b->getPosition()->getOrdering()) {
                        return -1;
                    }

                    if ($a->getPosition()->getOrdering() > $b->getPosition()->getOrdering()) {
                        return 1;
                    }

                    if ($a->isHolder() && !$b->isHolder()) {
                        return 1;
                    }

                    if (!$a->isHolder() && $b->isHolder()) {
                        return -1;
                    }

                    return 0;
                }
            );

        return $members;
    }

    public function getCurrentMembersWithoutPosition(?\DateTimeInterface $now = null)
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where($expr->isNull('position'));

        return $this->getCurrentMembers($now)->matching($criteria);
    }

    /**
     * Get the persons currently associated to the household.
     *
     * Return a list of Person, instead of a list of HouseholdMembers
     *
     * @return ReadableCollection<(int|string), Person>
     */
    public function getCurrentPersons(?\DateTimeImmutable $now = null): ReadableCollection
    {
        return $this->getCurrentMembers($now)
            ->map(static fn (HouseholdMember $m) => $m->getPerson());
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return Collection|HouseholdMember[]
     */
    public function getMembers(): Collection
    {
        return $this->members;
    }

    /**
     * get all the members during a given membership.
     *
     * @return ReadableCollection<(int|string), HouseholdMember>
     */
    public function getMembersDuringMembership(HouseholdMember $membership): ReadableCollection
    {
        return $this->getMembersOnRange(
            $membership->getStartDate(),
            $membership->getEndDate()
        )->filter(
            static fn (HouseholdMember $m) => $m->getPerson() !== $membership->getPerson()
        );
    }

    public function getMembersHolder(): Collection
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where(
            $expr->eq('holder', true)
        );

        return $this->getMembers()->matching($criteria);
    }

    public function getMembersOnRange(\DateTimeImmutable $from, ?\DateTimeImmutable $to): ReadableCollection
    {
        return $this->getMembers()->filter(static function (HouseholdMember $m) use ($from, $to) {
            if (null === $m->getEndDate() && null !== $to) {
                return $m->getStartDate() <= $to;
            }

            if (null === $to) {
                return $m->getStartDate() >= $from || null === $m->getEndDate();
            }

            if (null !== $m->getEndDate() && $m->getEndDate() < $from) {
                return false;
            }

            if ($m->getStartDate() <= $to) {
                return true;
            }

            return false;
        });
    }

    public function getNonCurrentMembers(?\DateTimeImmutable $now = null): Collection
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();
        $date = $now ?? new \DateTimeImmutable('today');

        $criteria
            ->where(
                $expr->gt('startDate', $date)
            )
            ->orWhere(
                $expr->andX(
                    $expr->lte('endDate', $date),
                    $expr->neq('endDate', null)
                )
            );

        return $this->getMembers()->matching($criteria);
    }

    public function getNonCurrentMembersByPosition(Position $position, ?\DateTimeInterface $now = null)
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where($expr->eq('position', $position));

        return $this->getNonCurrentMembers($now)->matching($criteria);
    }

    public function getNonCurrentMembersWithoutPosition(?\DateTimeInterface $now = null)
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();

        $criteria->where($expr->isNull('position'));

        return $this->getNonCurrentMembers($now)->matching($criteria);
    }

    /**
     * get all the unique persons during a given membership.
     *
     * same as @see(self::getMembersDuringMembership}, except that the collection is filtered to
     * return unique members.
     *
     * @return Collection|Person[]
     */
    public function getPersonsDuringMembership(HouseholdMember $member): Collection
    {
        // make list unique
        $membersByHash = [];

        foreach ($this->getMembersDuringMembership($member) as $m) {
            if (null !== $m && null !== $m->getPerson()) {
                $membersByHash[spl_object_hash($m->getPerson())] = $m->getPerson();
            }
        }

        return new ArrayCollection(array_values($membersByHash));
    }

    public function getPreviousAddressOf(Address $address): ?Address
    {
        $iterator = new \ArrayIterator($this->getAddressesOrdered());
        $iterator->rewind();

        while ($iterator->valid()) {
            $current = $iterator->current();
            $iterator->next();

            if ($iterator->valid()) {
                if ($iterator->current() === $address) {
                    return $current;
                }
            }
        }

        return null;
    }

    public function getWaitingForBirth(): bool
    {
        return $this->waitingForBirth;
    }

    public function getWaitingForBirthDate(): ?\DateTimeImmutable
    {
        return $this->waitingForBirthDate;
    }

    /**
     * @internal
     */
    public function householdCompositionConsistency(): void
    {
        $compositionOrdered = $this->compositions->toArray();

        usort(
            $compositionOrdered,
            static fn (HouseholdComposition $a, HouseholdComposition $b) => $a->getStartDate() <=> $b->getStartDate()
        );

        $iterator = new \ArrayIterator($compositionOrdered);
        $iterator->rewind();

        /** @var ?HouseholdComposition $previous */
        $previous = null;

        do {
            /** @var ?HouseholdComposition $current */
            $current = $iterator->current();

            if (null !== $previous) {
                if (null === $previous->getEndDate() || $previous->getEndDate() > $current->getStartDate()) {
                    $previous->setEndDate($current->getStartDate());
                }
            }
            $previous = $current;
            $iterator->next();
        } while ($iterator->valid());
    }

    public function makeAddressConsistent(): void
    {
        $iterator = new \ArrayIterator($this->getAddressesOrdered());

        $iterator->rewind();

        while ($iterator->valid()) {
            $current = $iterator->current();

            $iterator->next();

            if ($iterator->valid()) {
                $current->setValidTo($iterator->current()->getValidFrom());
            } else {
                $current->setValidTo(null);
            }
        }
    }

    public function removeAddress(Address $address)
    {
        $this->addresses->removeElement($address);
    }

    public function removeComposition(HouseholdComposition $composition): self
    {
        if ($this->compositions->removeElement($composition)) {
            $composition->setHousehold(null);
        }

        return $this;
    }

    public function removeMember(HouseholdMember $member): self
    {
        if ($this->members->removeElement($member)) {
            // set the owning side to null (unless already changed)
            if ($member->getHousehold() === $this) {
                $member->setHousehold(null);
            }
        }

        return $this;
    }

    public function setCommentMembers(CommentEmbeddable $commentMembers): self
    {
        $this->commentMembers = $commentMembers;

        return $this;
    }

    /**
     * Force an address starting at the current day
     * on the Household.
     *
     * This will force the startDate's address on today.
     *
     * Used on household creation.
     */
    #[Serializer\Groups(['create'])]
    public function setForceAddress(Address $address)
    {
        $address->setValidFrom(new \DateTime('today'));
        $this->addAddress($address);
    }

    public function setWaitingForBirth(bool $waitingForBirth): self
    {
        $this->waitingForBirth = $waitingForBirth;

        return $this;
    }

    public function setWaitingForBirthDate(?\DateTimeImmutable $waitingForBirthDate): self
    {
        $this->waitingForBirthDate = $waitingForBirthDate;

        return $this;
    }

    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context, $payload): void
    {
        $addresses = $this->getAddressesOrdered();

        for ($i = 0; \count($addresses) - 1 > $i; ++$i) {
            if (0 === $i) {
                continue;
            }
            if ($addresses[$i - 1]->getValidTo() !== $addresses[$i]->getValidFrom()) {
                $context->buildViolation('The address are not sequentials. The validFrom date of one address should be equal to the validTo date of the previous address.')
                    ->atPath('addresses')
                    ->addViolation();
            }
        }
    }

    private function buildCriteriaCurrentMembers(?\DateTimeImmutable $now = null): Criteria
    {
        $criteria = new Criteria();
        $expr = Criteria::expr();
        $date = $now ?? new \DateTimeImmutable('today');

        $criteria
            ->where($expr->orX(
                $expr->isNull('startDate'),
                $expr->lte('startDate', $date)
            ))
            ->andWhere($expr->orX(
                $expr->isNull('endDate'),
                $expr->gt('endDate', $date)
            ));

        return $criteria;
    }
}
