<?php

namespace App\Appointment\Booking\Service;

use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Auth\Entity\User;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Event\DomainEventPublisher;
use App\Shared\Event\DomainEvents;
use App\Shared\Exception\AppException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;

/**
 * رزرو موقت چندمنبعی.
 *
 * ## چرا دیتابیس، نه کد
 *
 * قانون سوم جمع‌بندی مستند: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد».
 * هر بررسیِ «آیا آزاد است؟» در PHP، بین خواندن و نوشتن یک پنجرهٔ مسابقه دارد؛ دو
 * درخواست هم‌زمان هر دو «آزاد» می‌بینند و هر دو می‌نویسند.
 *
 * پس تضمین روی کلید یکتای `(resource_id, bucket_at, seat)` است. کد فقط `INSERT`
 * می‌زند و اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».
 *
 * ## `seat` و ظرفیت
 *
 * منبع با ظرفیت ۳ سه صندلی دارد. تلاش از صندلی ۰ شروع می‌شود و با هر برخورد یک شماره
 * جلو می‌رود؛ وقتی همهٔ صندلی‌ها پر شد، `409` برمی‌گردد. شمردنِ ظرفیت در PHP همان
 * مسابقه‌ای را می‌ساخت که این طراحی حذفش می‌کند.
 */
final class HoldService
{
    public function __construct(
        private readonly DomainEventPublisher   $events,
        private readonly EntityManagerInterface $em,
    ) {}

    /**
     * @param array<string, list<ClinicResource>> $assignment نقش => منابع انتخابی
     * @throws AppException ۴۰۹ وقتی حتی یک منبع در حتی یک سطل جا ندارد
     */
    public function hold(
        User $user,
        AppointmentPlan $plan,
        array $assignment,
        int $startsAt,
        string $entityType,
        int $entityId,
        ?int $now = null,
    ): AppointmentHold {
        $now      = $now ?? time();
        $endsAt   = $startsAt + $plan->totalMinutes * 60;
        $reserved = $this->intervalsFor($plan, $assignment, $startsAt);

        if ($reserved === []) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                'برای این زمان هیچ منبعی مشخص نشده است',
                422,
                'assignment',
            );
        }

        $hold = new AppointmentHold(
            $user,
            $entityType,
            $entityId,
            $startsAt,
            $endsAt,
            [
                'assignment' => $this->describeAssignment($assignment),
                'plan'       => $plan->toArray(),
            ],
            $now,
        );

        $this->em->persist($hold);
        $this->em->flush();

        // اگر منبع دوم جا نداشت، اولی هم باید آزاد شود: رزرو نیمه‌کاره یعنی منبعی
        // قفل بماند که هرگز نوبتی رویش ثبت نمی‌شود.
        $taken = [];

        try {
            foreach ($reserved as $row) {
                $taken[] = $this->reserve($row['resource'], $row['start'], $row['end'], $hold, $row['segment']);
            }
        } catch (AppException $e) {
            $this->release($taken);
            $this->em->remove($hold);
            $this->em->flush();

            throw $e;
        }

        // بعد از اینکه **همهٔ** منابع گرفته شدند، نه پیش از آن: رزروی که وسط کار
        // شکسته، رویدادی هم ندارد.
        $this->events->recordAndFlush(
            $entityType,
            $entityId,
            DomainEvents::HOLD_CREATED,
            ['hold_uuid' => $hold->getUuid(), 'starts_at' => $startsAt, 'resources' => count($taken)],
            $now,
        );

        return $hold;
    }

    /**
     * یک بازه را برای یک منبع می‌گیرد، با اولین صندلی آزاد.
     *
     * سطل‌ها با DBAL خام نوشته می‌شوند نه با ORM: برخورد کلید یکتا در `flush()`
     * خودِ EntityManager را می‌بندد و تلاش صندلی بعدی هم با «EntityManager is closed»
     * می‌شکست. با DBAL، استثنا فقط یک استثناست و حلقه ادامه می‌یابد.
     *
     * @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند
     */
    private function reserve(
        ClinicResource $resource,
        int $start,
        int $end,
        AppointmentHold $hold,
        ?string $segmentName,
    ): ResourceOccupancy {
        $buckets    = OccupancyBucket::bucketsFor($start, $end);
        $connection = $this->em->getConnection();

        for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
            $occupancy = new ResourceOccupancy($resource, $start, $end, ResourceOccupancy::STATUS_HOLD);
            $occupancy->setSegmentName($segmentName);
            $occupancy->setHoldId($hold->getId());

            $this->em->persist($occupancy);
            $this->em->flush();

            try {
                foreach ($buckets as $bucketAt) {
                    $connection->insert('resource_occupancy_buckets', [
                        'resource_id'  => $resource->getId(),
                        'occupancy_id' => $occupancy->getId(),
                        'bucket_at'    => $bucketAt,
                        'seat'         => $seat,
                    ]);
                }

                return $occupancy;
            } catch (UniqueConstraintViolationException) {
                // این صندلی همین حالا گرفته شد. سطل‌های نیمه‌نوشته و خودِ ردیف اشغال
                // پاک می‌شوند تا صندلی بعدی از صفر شروع کند.
                $connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
                $this->em->remove($occupancy);
                $this->em->flush();
            }
        }

        throw new AppException(
            ErrorCodes::ERR_SLOT_TAKEN,
            sprintf('«%s» در این زمان ظرفیت خالی ندارد', $resource->getName()),
            409,
            'assignment',
        );
    }

    /**
     * آزادسازی: وضعیت `released` و پاک کردن سطل‌ها.
     *
     * خودِ ردیف اشغال می‌ماند چون تاریخچهٔ بهره‌وری است؛ ولی سطل‌ها باید بروند وگرنه
     * کلید یکتا آن زمان را برای همیشه قفل نگه می‌دارد.
     *
     * @param list<ResourceOccupancy> $occupancies
     */
    public function release(array $occupancies): void
    {
        if ($occupancies === []) {
            return;
        }

        $connection = $this->em->getConnection();

        foreach ($occupancies as $occupancy) {
            $connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
            $occupancy->markReleased();
        }

        $this->em->flush();
    }

    /** @return list<ResourceOccupancy> */
    public function occupanciesOfHold(AppointmentHold $hold): array
    {
        return $this->em->getRepository(ResourceOccupancy::class)->findBy(['holdId' => $hold->getId()]);
    }

    /**
     * بازه‌های اشغال: **per نقش**، نه per بخش.
     *
     * منبعی که در یک بخش نیازمندی ندارد، برای آن دقایق ردیف اشغال هم ندارد — همان
     * چیزی که ظرفیت را آزاد می‌کند (بند ۷ مستند).
     *
     * @param array<string, list<ClinicResource>> $assignment
     * @return list<array{resource: ClinicResource, start: int, end: int, segment: ?string}>
     */
    private function intervalsFor(AppointmentPlan $plan, array $assignment, int $startsAt): array
    {
        $rows = [];

        foreach ($plan->segments as $segment) {
            foreach ($segment->requirements as $requirement) {
                foreach ($assignment[$requirement->role] ?? [] as $resource) {
                    $segmentStart = $startsAt + $segment->offsetMinutes * 60;
                    $segmentEnd   = $segmentStart + $segment->durationMinutes * 60;

                    $rows[] = [
                        'resource' => $resource,
                        // آماده‌سازی و تمیزکاری هم گرفته می‌شود: منبع واقعاً در آن
                        // دقایق در دسترس نیست.
                        'start'    => $segmentStart - $requirement->setupMinutes * 60,
                        'end'      => $segmentEnd + $requirement->cleanupMinutes * 60,
                        'segment'  => $segment->name,
                    ];
                }
            }
        }

        return $rows;
    }

    /** @param array<string, list<ClinicResource>> $assignment */
    private function describeAssignment(array $assignment): array
    {
        $out = [];

        foreach ($assignment as $role => $resources) {
            $out[$role] = array_map(
                static fn (ClinicResource $r): array => ['uuid' => $r->getUuid(), 'name' => $r->getName()],
                $resources,
            );
        }

        return $out;
    }
}
