<?php

namespace App\Tests\Appointment;

use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
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\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;

/**
 * رزرو موقت و ثبت نهایی چندمنبعی — بند ۱۱ مستند.
 */
class HoldAndBookTest extends ApiTestCase
{
    private const TEHRAN = 'Asia/Tehran';

    private function nextSaturdayAt(int $hour): int
    {
        return (new \DateTimeImmutable('next saturday', new \DateTimeZone(self::TEHRAN)))
            ->setTime($hour, 0)
            ->getTimestamp();
    }

    /**
     * اشغال‌های یک منبع مشخص.
     *
     * `db_test` هرگز ریست نمی‌شود و تست‌های دیگر هم روی همین ساعت ردیف می‌سازند، پس
     * پرس‌وجو حتماً باید به منبعِ همین تست محدود شود — وگرنه تست، دادهٔ دیگران را
     * می‌شمارد.
     *
     * @return list<ResourceOccupancy>
     */
    private function occupancyOf(string $resourceUuid, ?int $startsAt = null): array
    {
        $qb = $this->em->createQueryBuilder()
            ->select('o')
            ->from(ResourceOccupancy::class, 'o')
            ->join('o.resource', 'r')
            ->where('r.uuid = :uuid')
            ->setParameter('uuid', $resourceUuid)
            ->orderBy('o.startsAt', 'ASC');

        if ($startsAt !== null) {
            $qb->andWhere('o.startsAt = :start')->setParameter('start', $startsAt);
        }

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

    /** @return array{user: User, doctor: Doctor, section: ServiceSection, address: DoctorAddress} */
    private function clinic(): array
    {
        $user   = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
        $clinic = new Clinic($user);
        $clinic->setName('کلینیک رزرو');
        $this->em->persist($clinic);
        $this->em->flush();

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

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

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

        return ['user' => $user, 'doctor' => $doctor, 'section' => $section, 'address' => $address];
    }

    private function service(ServiceSection $section, string $name, int $solo): ServiceItem
    {
        $section = $this->em->getRepository(ServiceSection::class)->find($section->getId());

        $item = new ServiceItem($section, $name);
        $item->setSoloDurationMinutes($solo);
        $this->em->persist($item);
        $this->em->flush();

        return $item;
    }

    private function type(DoctorAddress $address, string $code, string $name): ResourceType
    {
        $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
        $this->em->persist($type);
        $this->em->flush();

        return $type;
    }

    /** @param array<string, mixed> $extra */
    private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array
    {
        $created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
            'address_uuid' => $address->getUuid(),
            'type_uuid'    => $type->getUuid(),
            'name'         => $name,
        ]);
        self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));

        $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
            'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
        ]);

        return $created['data'];
    }

    /** @param list<array<string, mixed>> $segments */
    private function segments(User $user, ServiceItem $service, array $segments): void
    {
        $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]);
        self::assertSame(200, $this->responseCode());
    }

    /** @param array<string, list<string>> $assignment */
    private function hold(User $user, ServiceItem $service, DoctorAddress $address, int $start, array $assignment): array
    {
        return $this->authJson('POST', '/api/v1/appointment-hold', $user, [
            'service_uuid' => $service->getUuid(),
            'branch_uuid'  => $address->getUuid(),
            'start'        => $start,
            'assignment'   => $assignment,
        ]);
    }

    /** یک سرویس بیست‌دقیقه‌ای که فقط یک اتاق می‌خواهد. */
    private function simpleSetup(int $capacity = 1): array
    {
        $c       = $this->clinic();
        $service = $this->service($c['section'], 'ویزیت', 20);
        $room    = $this->type($c['address'], 'room', 'اتاق');
        $created = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱', ['capacity' => $capacity]);

        $this->segments($c['user'], $service, [
            ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
        ]);

        return $c + ['service' => $service, 'room' => $created];
    }

    public function testHoldCreatesOccupancyRowsPerSegmentAndResource(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(10);

        $body = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);

        self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
        self::assertNotEmpty($body['data']['hold_uuid']);
        self::assertGreaterThan(time(), $body['data']['expires_at']);

        $this->em->clear();
        $rows = $this->occupancyOf($s['room']['uuid'], $start);

        self::assertCount(1, $rows);
        self::assertSame(ResourceOccupancy::STATUS_HOLD, $rows[0]->getStatus());
        self::assertSame('ویزیت', $rows[0]->getSegmentName());
    }

    /** بعد از رزرو موقت، همان زمان دیگر پیشنهاد نمی‌شود. */
    public function testHeldTimeDisappearsFromAvailability(): void
    {
        $s        = $this->simpleSetup();
        $start    = $this->nextSaturdayAt(10);
        $saturday = $this->nextSaturdayAt(0);

        $before = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
            'service_uuid' => $s['service']->getUuid(),
            'branch_uuid'  => $s['address']->getUuid(),
            'from'         => $saturday,
            'to'           => $saturday,
            'step_minutes' => 20,
        ]);
        self::assertContains($start, array_column($before['data']['slots'], 'start'));

        $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
            'service_uuid' => $s['service']->getUuid(),
            'branch_uuid'  => $s['address']->getUuid(),
            'from'         => $saturday,
            'to'           => $saturday,
            'step_minutes' => 20,
        ]);

        self::assertNotContains($start, array_column($after['data']['slots'], 'start'));
    }

    /**
     * ⭐ تست همزمانی — اصلی‌ترین تست این تسک.
     *
     * دو رزرو روی همان منبع و همان بازه: دقیقاً یکی ۲۰۱ و دیگری ۴۰۹. تضمین از قید
     * یکتای دیتابیس می‌آید نه از بررسی کد، و همین تست آن قید را مستقیم هم می‌سنجد.
     */
    public function testSecondHoldOnTheSameResourceAndIntervalIsRejected(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(11);

        $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode(), json_encode($first, JSON_UNESCAPED_UNICODE));

        $second = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);

        self::assertSame(409, $this->responseCode());
        self::assertSame('ERR_SLOT_TAKEN', $second['errors'][0]['code']);
    }

    /**
     * خودِ قید دیتابیس، مستقل از هر کدِ PHP: نوشتن مستقیم دو ردیف یکسان باید با
     * نقض کلید یکتا رد شود. اگر این تست بشکند، یعنی تضمین فقط در کد بوده است.
     */
    public function testDatabaseItselfRefusesADuplicateBucketSeat(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(12);

        $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $this->em->clear();
        $occupancy = $this->occupancyOf($s['room']['uuid'], $start)[0];
        $bucket    = OccupancyBucket::bucketsFor($start, $start + 1200)[0];

        // اتصال جدا، نه اتصال مشترکِ تست: هم به «درخواست دیگر» وفادارتر است و هم
        // خطای عمدی، EntityManager مشترک را برای تست‌های بعدی خراب نمی‌کند.
        $connection = DriverManager::getConnection(
            $this->em->getConnection()->getParams(),
        );

        $this->expectException(UniqueConstraintViolationException::class);

        try {
            $connection->insert('resource_occupancy_buckets', [
                'resource_id'  => $occupancy->getResource()->getId(),
                'occupancy_id' => $occupancy->getId(),
                'bucket_at'    => $bucket,
                'seat'         => 0,
            ]);
        } finally {
            $connection->close();
        }
    }

    /** ظرفیت ۳: سه رزرو هم‌زمان می‌گذرند، چهارمی ۴۰۹. */
    public function testCapacityThreeAllowsThreeConcurrentHoldsAndRefusesTheFourth(): void
    {
        $s     = $this->simpleSetup(capacity: 3);
        $start = $this->nextSaturdayAt(13);

        foreach (range(1, 3) as $n) {
            $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
            self::assertSame(201, $this->responseCode(), "رزرو $n باید بگذرد");
        }

        $fourth = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);

        self::assertSame(409, $this->responseCode());
        self::assertSame('ERR_SLOT_TAKEN', $fourth['errors'][0]['code']);
    }

    public function testConfirmMarksEverythingBooked(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(14);

        $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
            'hold_uuid'   => $hold['data']['hold_uuid'],
            'doctor_uuid' => $s['doctor']->getUuid(),
        ]);

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

        $this->em->clear();
        $rows = $this->occupancyOf($s['room']['uuid'], $start);

        self::assertSame(ResourceOccupancy::STATUS_BOOKED, $rows[0]->getStatus());
        self::assertNotNull($rows[0]->getAppointmentId());
    }

    /** رزرو منقضی ثبت نمی‌شود و آن زمان دوباره آزاد است. */
    public function testExpiredHoldCannotBeConfirmed(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(15);

        $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        // مهلت را به عقب می‌بریم — همان کاری که گذر زمان می‌کند.
        $this->em->clear();
        $entity = $this->em->getRepository(AppointmentHold::class)->findOneBy(['uuid' => $hold['data']['hold_uuid']]);
        $this->em->getConnection()->update(
            'appointment_holds',
            ['expires_at' => time() - 60],
            ['id' => $entity->getId()],
        );

        $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
            'hold_uuid'   => $hold['data']['hold_uuid'],
            'doctor_uuid' => $s['doctor']->getUuid(),
        ]);

        self::assertSame(409, $this->responseCode());
        self::assertSame('ERR_HOLD_EXPIRED', $body['errors'][0]['code']);
    }

    /** آزادسازی زودهنگام: زمان دوباره در جستجو ظاهر می‌شود. */
    public function testReleasingAHoldFreesTheTimeAgain(): void
    {
        $s        = $this->simpleSetup();
        $start    = $this->nextSaturdayAt(16);
        $saturday = $this->nextSaturdayAt(0);

        $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $this->authJson('DELETE', "/api/v1/appointment-hold/{$hold['data']['hold_uuid']}", $s['user']);
        self::assertSame(200, $this->responseCode());

        $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
            'service_uuid' => $s['service']->getUuid(),
            'branch_uuid'  => $s['address']->getUuid(),
            'from'         => $saturday,
            'to'           => $saturday,
            'step_minutes' => 20,
        ]);

        self::assertContains($start, array_column($after['data']['slots'], 'start'));
    }

    /** رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳ — وجودش نباید لو برود. */
    public function testAnotherUsersHoldIsNotFound(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(17);

        $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        // مزاحم باید محیط معتبر خودش را داشته باشد، وگرنه ۴۰۳ «محیط انتخاب نشده»
        // می‌گیرد و تست چیزی را که ادعا می‌کند نمی‌سنجد.
        $intruder = $this->clinic()['user'];

        $this->authJson('POST', '/api/v1/appointment-confirm', $intruder, [
            'hold_uuid'   => $hold['data']['hold_uuid'],
            'doctor_uuid' => $s['doctor']->getUuid(),
        ]);

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

    /** نیازمندی‌ای که در `assignment` منبع ندارد → ۴۲۲، پیش از هر رزروی. */
    public function testAssignmentMissingARoleIsRejected(): void
    {
        $c        = $this->clinic();
        $service  = $this->service($c['section'], 'لیزر', 20);
        $room     = $this->type($c['address'], 'room', 'اتاق');
        $operator = $this->type($c['address'], 'operator', 'اپراتور');

        $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
        $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');

        $this->segments($c['user'], $service, [
            ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
                ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
            ]],
        ]);

        $body = $this->hold($c['user'], $service, $c['address'], $this->nextSaturdayAt(18), [
            'room' => [$roomRes['uuid']],   // اپراتور نیامده
        ]);

        self::assertSame(422, $this->responseCode());
        self::assertStringContainsString('اپراتور', $body['errors'][0]['message']);
    }

    /**
     * ⭐ آزادسازی ظرفیت حفظ می‌شود: اپراتور در بخش «انتظار» ردیف اشغال **ندارد**.
     */
    public function testOperatorHasNoOccupancyDuringTheWaitingSegment(): void
    {
        $c        = $this->clinic();
        $service  = $this->service($c['section'], 'لیزر', 20);
        $room     = $this->type($c['address'], 'room', 'اتاق');
        $operator = $this->type($c['address'], 'operator', 'اپراتور');

        $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
        $opRes   = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');

        $this->segments($c['user'], $service, [
            ['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5,  'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
            ['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]],
            ['sequence' => 3, 'name' => 'لیزر',   'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
        ]);

        $start = $this->nextSaturdayAt(19);

        $body = $this->hold($c['user'], $service, $c['address'], $start, [
            'room'     => [$roomRes['uuid']],
            'operator' => [$opRes['uuid']],
        ]);
        self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));

        $this->em->clear();

        $operatorRows = $this->occupancyOf($opRes['uuid']);

        self::assertCount(2, $operatorRows, 'دو بخش، نه یک بازهٔ پیوستهٔ ۵۵ دقیقه‌ای');
        self::assertSame($start, $operatorRows[0]->getStartsAt());
        self::assertSame($start + 5 * 60, $operatorRows[0]->getEndsAt());
        self::assertSame($start + 35 * 60, $operatorRows[1]->getStartsAt());

        // اتاق برعکس: هر سه بخش را می‌گیرد.
        self::assertCount(3, $this->occupancyOf($roomRes['uuid']));
    }

    /** رزرو نیمه‌کاره نمی‌ماند: اگر منبع دوم جا نداشت، اولی هم آزاد می‌شود. */
    public function testPartialHoldIsRolledBack(): void
    {
        $c        = $this->clinic();
        $service  = $this->service($c['section'], 'لیزر', 20);
        $room     = $this->type($c['address'], 'room', 'اتاق');
        $operator = $this->type($c['address'], 'operator', 'اپراتور');

        $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
        $opRes   = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');

        $this->segments($c['user'], $service, [
            ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
                ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
            ]],
        ]);

        $start = $this->nextSaturdayAt(20);

        // اپراتور را از پیش می‌گیریم تا رزرو دوم روی او شکست بخورد.
        $onlyOperator = $this->service($c['section'], 'کار اپراتور', 20);
        $this->segments($c['user'], $onlyOperator, [
            ['sequence' => 1, 'name' => 'کار', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $operator->getUuid()]]],
        ]);
        $this->hold($c['user'], $onlyOperator, $c['address'], $start, ['operator' => [$opRes['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $body = $this->hold($c['user'], $service, $c['address'], $start, [
            'room'     => [$roomRes['uuid']],
            'operator' => [$opRes['uuid']],
        ]);
        self::assertSame(409, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));

        // اتاق نباید قفل مانده باشد.
        $this->em->clear();
        $roomRows = $this->em->createQuery(
            'SELECT o FROM App\Appointment\Availability\Entity\ResourceOccupancy o
             JOIN o.resource r WHERE r.uuid = :uuid AND o.status IN (:blocking)'
        )
            ->setParameter('uuid', $roomRes['uuid'])
            ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
            ->getResult();

        self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند');
    }

    /**
     * جابه‌جایی از بیرون شبیه «لغو + رزرو» است، ولی برای بیمار یک اتفاق است.
     *
     * رویداد `AppointmentRescheduled` همین را می‌گوید؛ بدون آن، مصرف‌کننده‌ای که فقط
     * `AppointmentCancelled` را می‌شنود برای بیماری که هنوز نوبت دارد پیام لغو می‌فرستد.
     */
    public function testRebookingMovesTheAppointmentAndRecordsTheEvent(): void
    {
        $s     = $this->simpleSetup();
        $start = $this->nextSaturdayAt(14);

        $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
        $book  = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
            'hold_uuid'   => $first['data']['hold_uuid'],
            'doctor_uuid' => $s['doctor']->getUuid(),
        ]);
        self::assertSame(200, $this->responseCode(), json_encode($book, JSON_UNESCAPED_UNICODE));

        $newStart = $start + 2 * 3600;
        $second   = $this->hold($s['user'], $s['service'], $s['address'], $newStart, ['room' => [$s['room']['uuid']]]);
        self::assertSame(201, $this->responseCode());

        $moved = $this->authJson(
            'POST',
            "/api/v1/appointment/{$book['data']['appointment_uuid']}/rebook",
            $s['user'],
            ['hold_uuid' => $second['data']['hold_uuid']],
        );

        self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE));
        self::assertSame($newStart, $moved['data']['starts_at']);

        $event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class)
            ->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']);

        self::assertNotNull($event);
        self::assertSame($start, $event->getPayload()['previous_start']);
        self::assertSame($newStart, $event->getPayload()['new_start']);
    }
}
