<?php

namespace App\Tests\Waitlist;

use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use App\Waitlist\Service\WaitlistNotifier;

/**
 * لیست انتظار — تسک ۱۳.
 *
 * تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده می‌شود و اولین رزروکننده می‌برد.
 * صف انحصاری یعنی وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
 * مانده به نوبت آن نیم ساعت را ندارد.
 */
class WaitlistTest extends ApiTestCase
{
    private int $slotCursor = 0;

    /** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
    private function clinicWithPatient(): array
    {
        $user   = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
        $clinic = new Clinic($user);
        $clinic->setName('کلینیک انتظار');
        $this->em->persist($clinic);
        $this->em->flush();

        $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
        $this->em->persist($section);

        $address = DoctorAddress::forClinic($clinic->getId());
        $address->setName('شعبهٔ مرکزی');
        $this->em->persist($address);

        $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
        $doctor     = new Doctor($doctorUser, 'دکتر انتظار');
        $this->em->persist($doctor);

        $patientUser = $this->createUser(['ROLE_USER']);
        $patient     = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
        $this->em->persist($patient);
        $this->em->flush();

        return [$user, $section, $address, $doctor, $patient];
    }

    private function extraPatient(int $clinicId): PatientRecord
    {
        $user    = $this->createUser(['ROLE_USER']);
        $patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId);
        $this->em->persist($patient);
        $this->em->flush();

        return $patient;
    }

    private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
    {
        $item = new ServiceItem($section, $name);
        $item->setSoloDurationMinutes(30);
        $item->setPriceRials(4_000_000);
        $this->em->persist($item);
        $this->em->flush();

        return $item;
    }

    /** @param array<string, mixed> $extra */
    private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array
    {
        $body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [
            'patient_uuid' => $patient->getUuid(),
            'service_uuid' => $service->getUuid(),
            'desired_from' => $from,
            'desired_to'   => $to,
        ]);

        self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));

        return $body['data'];
    }

    private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment
    {
        $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);

        $start += (++$this->slotCursor) * 60;

        $appointment = new Appointment(
            $em->getRepository(Doctor::class)->find($doctor->getId()),
            $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
            $start,
            $start + 1800,
        );
        $appointment->assignTenantPair('clinic', (int) $address->getClinicId());
        $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
        $appointment->setAddressId($address->getId());
        $appointment->setPatientName('بیمار نوبت');
        $appointment->transitionTo(Appointment::STATUS_CONFIRMED);

        $em->persist($appointment);
        $em->flush();

        return $appointment;
    }

    private function notifier(): WaitlistNotifier
    {
        return static::getContainer()->get(WaitlistNotifier::class);
    }

    // ── ثبت ─────────────────────────────────────────────────────────────────

    public function testJoiningTheWaitlistStoresTheWindow(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $from  = time() + 86400;
        $to    = $from + 3 * 86400;
        $entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]);

        self::assertSame('waiting', $entry['status']);
        self::assertSame($from, $entry['desired_from']);
        self::assertSame(['evening'], $entry['preferred_day_parts']);
        self::assertSame(0, $entry['notify_count']);

        $list = $this->authJson('GET', '/api/v1/waitlist', $user);
        self::assertCount(1, $list['data']);
    }

    /** انتظار برای بازهٔ گذشته هرگز به نتیجه نمی‌رسد. */
    public function testAPastWindowIsRejected(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $this->authJson('POST', '/api/v1/waitlist', $user, [
            'patient_uuid' => $patient->getUuid(),
            'service_uuid' => $service->getUuid(),
            'desired_from' => time() - 5 * 86400,
            'desired_to'   => time() - 86400,
        ]);

        self::assertSame(422, $this->responseCode());
    }

    public function testAnInvertedWindowIsRejected(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $this->authJson('POST', '/api/v1/waitlist', $user, [
            'patient_uuid' => $patient->getUuid(),
            'service_uuid' => $service->getUuid(),
            'desired_from' => time() + 5 * 86400,
            'desired_to'   => time() + 86400,
        ]);

        self::assertSame(422, $this->responseCode());
    }

    // ── تطبیق و اطلاع ───────────────────────────────────────────────────────

    public function testMatchesFindsEveryoneWaitingForThatMoment(): void
    {
        [$user, $section, $address, , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $from = time() + 86400;
        $to   = $from + 3 * 86400;

        $this->join($user, $patient, $service, $from, $to);
        $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);

        // کسی که بازه‌اش پوشش نمی‌دهد نباید بیاید.
        $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400);

        $start   = $from + 3600;
        $matches = $this->authJson(
            'GET',
            sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start),
            $user,
        );

        self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE));
        self::assertCount(2, $matches['data']);
    }

    /** ⭐ همه خبر می‌شوند — نه فقط نفر اول. */
    public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $slot = time() + 2 * 86400;
        $from = $slot - 86400;
        $to   = $slot + 86400;

        $first  = $this->join($user, $patient, $service, $from, $to);
        $second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $slot);

        $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);

        self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
        self::assertSame(2, $body['data']['waitlist_notified']);

        $repo = static::getContainer()->get(WaitlistEntryRepository::class);

        foreach ([$first['uuid'], $second['uuid']] as $uuid) {
            $entry = $repo->findByUuid($uuid);
            self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus());
            self::assertNotNull($entry->getNotifiedAt());
            self::assertSame(1, $entry->getNotifyCount());
        }
    }

    /** سقف اطلاع‌رسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمی‌کند. */
    public function testNotificationsStopAtTheCap(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $slot = time() + 2 * 86400;
        $entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400);

        $repo = static::getContainer()->get(WaitlistEntryRepository::class);

        for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) {
            $appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
            $this->notifier()->notifyForFreedSlot($appointment);
        }

        self::assertSame(
            WaitlistEntry::MAX_NOTIFICATIONS,
            $repo->findByUuid($entry['uuid'])->getNotifyCount(),
        );
    }

    /** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمی‌شود. */
    public function testAnEntryForAnotherBranchIsNotNotified(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $otherBranch = DoctorAddress::forClinic((int) $address->getClinicId());
        $otherBranch->setName('شعبهٔ دوم');
        $this->em->persist($otherBranch);
        $this->em->flush();

        $slot = time() + 2 * 86400;

        $this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [
            'branch_uuid' => $otherBranch->getUuid(),
        ]);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $slot);

        self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
    }

    public function testDeletingAnEntryRemovesItFromTheList(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400);

        $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user);
        self::assertSame(200, $this->responseCode());

        self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']);
    }

    public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void
    {
        [$owner, $section, , , $patient] = $this->clinicWithPatient();
        [$other] = $this->clinicWithPatient();

        $service = $this->service($section);
        $entry   = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400);

        self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']);

        $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
        self::assertSame(404, $this->responseCode());
    }

    // ── بخش روز ─────────────────────────────────────────────────────────────

    /**
     * ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
     *
     * ذخیره‌کردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
     * فکر می‌کند سیستم حرفش را شنیده.
     */
    public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $morning = $this->localHour(9);

        $this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
            'preferred_day_parts' => ['evening'],
        ]);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $morning);

        self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
    }

    public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $evening = $this->localHour(19);

        $this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
            'preferred_day_parts' => ['evening'],
        ]);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $evening);

        self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
    }

    /** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
    public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $dawn = $this->localHour(5);

        $this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);

        self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
    }

    public function testAnUnknownDayPartIsRejected(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $this->authJson('POST', '/api/v1/waitlist', $user, [
            'patient_uuid'        => $patient->getUuid(),
            'service_uuid'        => $service->getUuid(),
            'desired_from'        => time() + 86400,
            'desired_to'          => time() + 3 * 86400,
            'preferred_day_parts' => ['midnight'],
        ]);

        self::assertSame(422, $this->responseCode());
    }

    // ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────

    public function testARangeLongerThanNinetyDaysIsRejected(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $from = time() + 86400;

        $this->authJson('POST', '/api/v1/waitlist', $user, [
            'patient_uuid' => $patient->getUuid(),
            'service_uuid' => $service->getUuid(),
            'desired_from' => $from,
            'desired_to'   => $from + 91 * 86400,
        ]);

        self::assertSame(422, $this->responseCode());
    }

    /**
     * ⭐ ردیفِ منقضی از قبل هم در تطبیق نمی‌آمد؛ این پاکسازیِ **نمایش** است تا اپراتور
     * بفهمد کدام انتظار هنوز زنده است.
     */
    public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
    {
        [$user, $section, , , $patient] = $this->clinicWithPatient();
        $service = $this->service($section);

        $live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
        $dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);

        // بازهٔ ردیف دوم را به گذشته می‌بریم — API عمداً بازهٔ گذشته را نمی‌پذیرد.
        $this->em->getConnection()->executeStatement(
            'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
            [time() - 10 * 86400, time() - 86400, $dead['uuid']],
        );

        $expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
        self::assertSame(1, $expired);

        // خواندن مستقیم از دیتابیس: `expire()` با SQL خام می‌نویسد، پس هر نقشهٔ هویتِ
        // باز، نسخهٔ کهنه را برمی‌گرداند.
        self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
        self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
    }

    /**
     * ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
     * چیزی که هنوز منتظرش است دیگر هرگز خبر نمی‌شود.
     */
    public function testBookingConvertsOnlyTheMatchingEntry(): void
    {
        [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
        $service = $this->service($section);
        $other   = $this->service($section, 'بوتاکس');

        $start = time() + 2 * 86400;

        $mine      = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
        $unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);

        $appointment = $this->appointment($doctor, $patient, $service, $address, $start);

        $converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
        self::assertSame(1, $converter->convertFor($appointment));

        // اجرای دوباره چیزی را دوباره نمی‌بندد — تحویل دوبارهٔ پیام بی‌خطر است.
        self::assertSame(0, $converter->convertFor($appointment));

        $this->em->clear();
        $repo = static::getContainer()->get(WaitlistEntryRepository::class);

        self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
        self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
    }

    private function statusOf(string $uuid): string
    {
        return (string) $this->em->getConnection()->fetchOne(
            'SELECT status FROM waitlist_entries WHERE uuid = ?',
            [$uuid],
        );
    }

    /** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
    private function localHour(int $hour): int
    {
        return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
            ->setTime($hour, 0)
            ->getTimestamp();
    }
}
