<?php

namespace App\Appointment\Repository;

use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\ManagerRegistry;

class AppointmentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Appointment::class);
    }

    public function findByUuid(string $uuid): ?Appointment
    {
        return $this->findOneBy(['uuid' => $uuid]);
    }

    /**
     * Persist a booking atomically: re-check the slot inside a transaction so
     * two concurrent requests for the same slot cannot both succeed.
     *
     * @throws SlotTakenException if the slot is taken when the transaction commits
     */
    public function bookAtomically(Appointment $appointment): void
    {
        $em = $this->getEntityManager();
        try {
            $em->wrapInTransaction(function () use ($em, $appointment): void {
                $doctor = $appointment->getDoctor();
                $start  = $appointment->getSlotStart();
                $end    = $appointment->getSlotEnd();

                if ($this->isSlotTaken($doctor, $start, $end)) {
                    throw new SlotTakenException();
                }
                // Free the unique slot key of any pending booking whose payment
                // window has lapsed but the expiry cron hasn't run yet, so the
                // new booking can take the slot.
                $this->expireLapsedPending($doctor, $start, $end);

                $em->persist($appointment);
                $em->flush();
            });
        } catch (UniqueConstraintViolationException) {
            // Lost the race on the (doctor, slot_start) unique key — the
            // in-transaction re-check passed for two concurrent requests but
            // only one INSERT can win.
            throw new SlotTakenException();
        }
    }

    /**
     * Expire pending bookings overlapping this slot whose payment window has
     * lapsed, releasing their active_slot_key so the slot can be rebooked.
     */
    private function expireLapsedPending(Doctor $doctor, int $slotStart, int $slotEnd): void
    {
        $lapsed = $this->createQueryBuilder('a')
            ->where('a.doctor = :doctor')
            ->andWhere('a.slotStart < :slotEnd')
            ->andWhere('a.slotEnd > :slotStart')
            ->andWhere('a.status = :pending')
            ->andWhere('a.expiresAt IS NOT NULL AND a.expiresAt <= :now')
            ->setParameter('doctor', $doctor)
            ->setParameter('pending', Appointment::STATUS_PENDING)
            ->setParameter('slotStart', $slotStart)
            ->setParameter('slotEnd', $slotEnd)
            ->setParameter('now', time())
            ->getQuery()
            ->getResult();

        if ($lapsed === []) {
            return;
        }
        foreach ($lapsed as $appt) {
            $appt->transitionTo(Appointment::STATUS_EXPIRED);
        }
        // Flush the key-releasing UPDATEs before the new INSERT so they don't
        // collide on the unique key within the same flush.
        $this->getEntityManager()->flush();
    }

    /** Check if a slot is already taken (confirmed or pending) */
    public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
    {
        $qb = $this->createQueryBuilder('a')
            ->select('COUNT(a.id)')
            ->where('a.doctor = :doctor')
            ->andWhere('a.slotStart < :slotEnd')
            ->andWhere('a.slotEnd > :slotStart')
            ->andWhere(
                'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
            )
            ->setParameter('doctor', $doctor)
            ->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
            ->setParameter('pending', Appointment::STATUS_PENDING)
            ->setParameter('now', time())
            ->setParameter('slotStart', $slotStart)
            ->setParameter('slotEnd', $slotEnd);

        if ($excludeId !== null) {
            $qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
        }

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

    /** @return Appointment[] */
    public function findByDoctor(Doctor $doctor, ?string $status = null): array
    {
        $criteria = ['doctor' => $doctor];
        if ($status !== null) $criteria['status'] = $status;
        return $this->findBy($criteria, ['slotStart' => 'ASC']);
    }

    /** @return Appointment[] */
    public function findByUser(User $user, ?string $status = null): array
    {
        $criteria = ['user' => $user];
        if ($status !== null) $criteria['status'] = $status;
        return $this->findBy($criteria, ['slotStart' => 'DESC']);
    }

    /**
     * نوبت‌های یک بیمار (کاربر) که با پزشک(های) مشخص گرفته شده‌اند — برای نمایش در
     * پروندهٔ بیمار. اگر لیست پزشک خالی باشد، آرایهٔ خالی برمی‌گرداند.
     *
     * @param int[] $doctorIds
     * @return Appointment[]
     */
    public function findByUserAndDoctorIds(User $user, array $doctorIds): array
    {
        if ($doctorIds === []) {
            return [];
        }

        return $this->createQueryBuilder('a')
            ->where('a.user = :user')
            ->andWhere('a.doctor IN (:doctorIds)')
            ->setParameter('user', $user)
            ->setParameter('doctorIds', $doctorIds)
            ->orderBy('a.slotStart', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */
    public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool
    {
        $now   = time();
        $since = $now - $sinceDays * 86400;

        $count = (int) $this->createQueryBuilder('a')
            ->select('COUNT(a.id)')
            ->where('a.user = :user')
            ->andWhere('a.doctor = :doctor')
            ->andWhere('a.status = :status')
            ->andWhere('a.slotStart >= :since')
            ->andWhere('a.slotStart <= :now')
            ->setParameter('user', $user)
            ->setParameter('doctor', $doctor)
            ->setParameter('status', Appointment::STATUS_CONFIRMED)
            ->setParameter('since', $since)
            ->setParameter('now', $now)
            ->getQuery()
            ->getSingleScalarResult();

        return $count > 0;
    }

    /** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
    public function findPaymentExpired(int $now): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.status = :status')
            ->andWhere('a.expiresAt IS NOT NULL')
            ->andWhere('a.expiresAt < :now')
            ->setParameter('status', Appointment::STATUS_PENDING)
            ->setParameter('now', $now)
            ->getQuery()
            ->getResult();
    }

    /** @return Appointment[] pending appointments older than given timestamp */
    public function findExpiredPending(int $before): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.status = :status')
            ->andWhere('a.slotStart < :before')
            ->setParameter('status', Appointment::STATUS_PENDING)
            ->setParameter('before', $before)
            ->getQuery()
            ->getResult();
    }

    public function save(Appointment $entity, bool $flush = true): void
    {
        $this->getEntityManager()->persist($entity);
        if ($flush) $this->getEntityManager()->flush();
    }

    /**
     * @throws OptimisticLockException
     */
    public function saveWithLock(Appointment $entity, int $expectedVersion): void
    {
        $this->getEntityManager()->lock($entity, \Doctrine\DBAL\LockMode::OPTIMISTIC, $expectedVersion);
        $this->getEntityManager()->persist($entity);
        $this->getEntityManager()->flush();
    }

    public function remove(Appointment $entity, bool $flush = true): void
    {
        $this->getEntityManager()->remove($entity);
        if ($flush) $this->getEntityManager()->flush();
    }
}
