<?php

namespace App\Appointment\Repository;

use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\LockMode;
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();

                // قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
                // هم‌زمانِ یک پزشک را سریالایز می‌کند. در حالت نوبت‌دهی سرویسی که
                // نوبت‌ها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
                // تداخلِ بازه‌ایِ دو رزروِ هم‌زمان را نمی‌گیرد؛ این قفل تضمین می‌کند
                // بررسیِ isSlotTaken و insert به‌صورت اتمیک نسبت به سایر رزروها انجام شود.
                $em->lock($doctor, LockMode::PESSIMISTIC_WRITE);

                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();
    }

    /**
     * بازه‌های اشغال‌شدهٔ یک پزشک در پنجرهٔ [$from, $to) — برای محاسبهٔ زمانِ خالی
     * در حالت نوبت‌دهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)،
     * ولی نوبت‌های «آزاد» (is_reserve) هیچ بازه‌ای اشغال نمی‌کنند.
     *
     * `$excludeId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال جابه‌جایی خودش
     * را اشغال می‌بیند و زمان فعلی‌اش هرگز در فهرست زمان‌های ممکن نمی‌آید — پس کاربر
     * نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند. همان الگوی
     * {@see isSlotTaken()} که این پارامتر را از قبل دارد.
     *
     * @return array<array{start:int,end:int}> مرتب‌شده بر اساس start
     */
    public function findBusyIntervals(Doctor $doctor, int $from, int $to, ?int $excludeId = null): array
    {
        return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId);
    }

    /**
     * همان مجموعه‌ای که isSlotTaken() یک اسلات را با آن می‌سنجد — شامل نوبت‌های
     * رزروی. برای پیمایش چندروزه که نمی‌خواهد به ازای هر اسلات یک کوئری بزند.
     *
     * @return array<int, array{start: int, end: int}>
     */
    public function findBlockingIntervals(Doctor $doctor, int $from, int $to): array
    {
        return $this->occupiedIntervals($doctor, $from, $to, false);
    }

    /** @return array<int, array{start: int, end: int}> */
    private function occupiedIntervals(
        Doctor $doctor,
        int $from,
        int $to,
        bool $skipReserve,
        ?int $excludeId = null,
    ): array {
        $qb = $this->createQueryBuilder('a')
            ->select('a.slotStart AS start, a.slotEnd AS end')
            ->where('a.doctor = :doctor')
            ->andWhere('a.slotStart < :to')
            ->andWhere('a.slotEnd > :from')
            ->andWhere(
                'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
            )
            ->setParameter('doctor', $doctor)
            ->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
            ->setParameter('pending', Appointment::STATUS_PENDING)
            ->setParameter('now', time())
            ->setParameter('from', $from)
            ->setParameter('to', $to)
            ->orderBy('a.slotStart', 'ASC');

        if ($skipReserve) {
            $qb->andWhere('a.isReserve = false');
        }

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

        $rows = $qb->getQuery()->getScalarResult();

        return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows);
    }

    /** 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 IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
            )
            ->setParameter('doctor', $doctor)
            ->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
            ->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, ?Clinic $clinic = null): array
    {
        $criteria = ['doctor' => $doctor];
        if ($status !== null) $criteria['status'] = $status;
        // محدودکردن به یک محیط: مدیر کلینیک نباید نوبت‌های مطب شخصی پزشک را ببیند.
        if ($clinic !== null) $criteria['clinic'] = $clinic;
        return $this->findBy($criteria, ['slotStart' => 'ASC']);
    }

    /**
     * Filtered + paginated appointment list for one doctor — backs the doctor
     * dashboard filter bar. Kept separate from findByDoctor() so existing
     * unfiltered callers keep their plain-array contract.
     *
     * @param string[] $statuses  empty = no status restriction
     * @return array{items: Appointment[], total: int}
     */
    public function searchByDoctor(
        Doctor   $doctor,
        array    $statuses = [],
        ?Clinic  $clinic = null,
        ?int     $from = null,
        ?int     $to = null,
        ?string  $query = null,
        ?string  $serviceUuid = null,
        int      $page = 1,
        int      $limit = 20,
    ): array {
        $qb = $this->createQueryBuilder('a')
            ->join('a.user', 'u')
            ->where('a.doctor = :doctor')
            ->setParameter('doctor', $doctor);

        if ($statuses !== []) {
            $qb->andWhere('a.status IN (:statuses)')->setParameter('statuses', $statuses);
        }
        // همان محدودسازی محیط که findByDoctor دارد: نوبت مطب شخصی به کلینیک نشت نکند.
        if ($clinic !== null) {
            $qb->andWhere('a.clinic = :clinic')->setParameter('clinic', $clinic);
        }
        if ($from !== null) {
            $qb->andWhere('a.slotStart >= :from')->setParameter('from', $from);
        }
        if ($to !== null) {
            $qb->andWhere('a.slotStart <= :to')->setParameter('to', $to);
        }
        if ($serviceUuid !== null && $serviceUuid !== '') {
            $qb->join('a.serviceItem', 'si')
               ->andWhere('si.uuid = :serviceUuid')
               ->setParameter('serviceUuid', $serviceUuid);
        }
        // نام/موبایل هم روی فیلدهای خودِ نوبت ذخیره می‌شود و هم روی کاربر؛ هر دو جست‌وجو می‌شوند.
        if ($query !== null && trim($query) !== '') {
            $qb->andWhere('a.patientName LIKE :q OR a.patientMobile LIKE :q OR u.realName LIKE :q OR u.mobileNumber LIKE :q')
               ->setParameter('q', '%' . trim($query) . '%');
        }

        $total = (int) (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();

        $items = $qb->orderBy('a.slotStart', 'ASC')
            ->setFirstResult(max(0, ($page - 1) * $limit))
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();

        return ['items' => $items, 'total' => $total];
    }

    /**
     * نوبت‌های یک بیمار در یک کلینیک — بر پایهٔ خودِ محیطِ ثبت‌شدهٔ نوبت، تا غیرفعال
     * شدنِ بعدیِ پزشک تاریخچه را از پروندهٔ کلینیک حذف نکند.
     *
     * @return Appointment[]
     */
    public function findByUserAndClinic(User $user, int $clinicId): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.user = :user')
            ->andWhere('IDENTITY(a.clinic) = :clinicId')
            ->setParameter('user', $user)
            ->setParameter('clinicId', $clinicId)
            ->orderBy('a.slotStart', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /** @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();
    }

    /**
     * رزروهای آنلاینِ پرداخت‌نشده که ساعتشان هم گذشته است.
     *
     * `expiresAt IS NOT NULL` یعنی فقط نگه‌داشتِ موقتِ درگاه (markPendingWithTtl).
     * نوبت «ثبت‌شده»ای که کلینیک/پزشک از پنل ثبت کرده TTL ندارد و نباید سرِ ساعتِ
     * نوبت خودبه‌خود منقضی شود — قطعی/لغو کردنش تصمیم اپراتور است.
     *
     * @return Appointment[]
     */
    public function findExpiredPending(int $before): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.status = :status')
            ->andWhere('a.expiresAt IS NOT NULL')
            ->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();
    }
}
