<?php

namespace App\Appointment\Controller;

use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Security\AppointmentAccessChecker;
use App\Appointment\Service\AppointmentConfirmationService;
use App\Appointment\Service\SlotCalculatorService;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Service\InputValidator;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\OptimisticLockException;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[OA\Tag(name: 'Appointments')]
class AppointmentController extends BaseController
{
    public function __construct(
        private readonly AppointmentRepository  $appointmentRepo,
        private readonly DoctorRepository       $doctorRepo,
        private readonly SlotCalculatorService  $slotCalculator,
        private readonly AppointmentConfirmationService $appointmentConfirmation,
        private readonly WeeklyScheduleRepository $scheduleRepo,
        private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
        private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
        private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
        private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
        private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
        private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
        private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
        private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
        private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
        private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
        private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
        private readonly \App\Shared\Event\DomainEventPublisher $domainEvents,
        private readonly \Psr\Log\LoggerInterface $logger,
    ) {}

    private const CANCEL_STATUSES = [
        Appointment::STATUS_CANCELLED_BY_DOCTOR,
        Appointment::STATUS_CANCELLED_BY_USER,
    ];

    /**
     * ثبت رویداد لغو در Timeline نوبت + لاگ سطح warning (تا در app_log هم persist شود).
     * بعد از ذخیره‌ی موفق نوبت صدا زده می‌شود.
     */
    private function recordCancellation(Appointment $appointment, string $status, ?string $reason, User $user): void
    {
        $actorName = $user->getRealName() ?: $user->getMobileNumber();

        $event = new \App\Appointment\Entity\AppointmentEvent($appointment, \App\Appointment\Entity\AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
        $event->setActor($user->getId(), $actorName);
        $event->setReason($reason);
        $this->eventRepo->save($event);

        $this->logger->warning(sprintf(
            'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
            $appointment->getUuid(), $status, (int) $user->getId(), $actorName, $reason ?? '-'
        ));
    }

    /**
     * ثبت رویداد دامنهٔ «نوبت انجام شد».
     *
     * جدا از `AppointmentEvent` است و جایگزینش نمی‌شود: آن، تایم‌لاینِ خوانده‌شده توسط
     * اپراتور است و این، صندوق خروجی برای مصرف‌کننده‌های بیرونی. هر دو مسیرِ تغییر
     * وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا می‌رسند، چون
     * رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
     */
    private function recordCompletion(Appointment $appointment): void
    {
        $this->domainEvents->recordAndFlush(
            $appointment->getEntityType(),
            $appointment->getEntityId(),
            \App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
            [
                'appointment_uuid' => $appointment->getUuid(),
                'slot_start'       => $appointment->getSlotStart(),
            ],
        );
    }

    // ── Public: available slots ───────────────────────────────────────────────

    #[OA\Get(
        path: '/api/v1/appointment-slots',
        summary: 'Get available appointment slots for a doctor on a given date',
        parameters: [
            new OA\Parameter(
                name: 'doctor_uuid',
                in: 'query',
                required: true,
                schema: new OA\Schema(type: 'string', format: 'uuid')
            ),
            new OA\Parameter(
                name: 'date',
                in: 'query',
                required: true,
                description: 'Date in Y-m-d format',
                schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15')
            ),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: 'Available slots returned',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(
                            property: 'data',
                            properties: [
                                new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
                                new OA\Property(property: 'date', type: 'string', format: 'date'),
                                new OA\Property(
                                    property: 'slots',
                                    type: 'array',
                                    items: new OA\Items(
                                        properties: [
                                            new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'),
                                            new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'),
                                            new OA\Property(property: 'available', type: 'boolean'),
                                        ],
                                        type: 'object'
                                    )
                                ),
                            ],
                            type: 'object'
                        ),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(
                response: 404,
                description: 'Doctor not found',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: false),
                        new OA\Property(
                            property: 'errors',
                            type: 'array',
                            items: new OA\Items(
                                properties: [
                                    new OA\Property(property: 'code', type: 'string'),
                                    new OA\Property(property: 'message', type: 'string'),
                                ],
                                type: 'object'
                            )
                        ),
                    ]
                )
            ),
            new OA\Response(response: 422, description: 'Invalid date format'),
        ]
    )]
    #[Route('/api/v1/appointment-slots', methods: ['GET'])]
    public function slots(Request $request): JsonResponse
    {
        $doctorUuid = trim($request->query->get('doctor_uuid', ''));
        $date       = trim($request->query->get('date', ''));

        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
        }

        $clinic         = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
        $forManagement  = $this->isManagementContext($request, $doctor, $clinic);
        $sessions       = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);

        return $this->success([
            'doctor_uuid' => $doctorUuid,
            'clinic_uuid' => $clinic?->getUuid(),
            'date'        => $date,
            'sessions'    => $sessions,
            // خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
            'empty_reason' => $sessions === []
                ? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement)
                : null,
        ]);
    }

    /**
     * حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده.
     * فقط سرویس‌های «نمایش در نوبت‌دهی» (bookable) و دارای مدت پذیرفته می‌شوند.
     *
     * GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
     */
    #[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
    public function serviceSlots(Request $request): JsonResponse
    {
        $doctorUuid = trim($request->query->get('doctor_uuid', ''));
        $date       = trim($request->query->get('date', ''));

        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }
        if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
        }

        $clinic   = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        $mode     = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
        if ($mode !== WeeklySchedule::MODE_SERVICE) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
        }

        $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
        if (empty($uuids)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
        }

        // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes
        // مالکیت محیط، وجود، فعال‌بودن و مدت — همه داخل calculate() و با همان ترتیب و
        // همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope می‌سازند).
        $duration = $this->serviceCalculator->calculate(
            $doctor,
            $clinic,
            $uuids,
            (array) $request->query->all('durations'),
        );

        // جابه‌جایی: بازهٔ خودِ نوبتِ در حال ویرایش نباید اشغال حساب شود، وگرنه زمان
        // فعلی‌اش هرگز در فهرست نمی‌آید. فقط برای کسی که همان نوبت را مدیریت می‌کند —
        // این پارامتر آزاد نیست، چون در غیر این‌صورت هر کسی می‌توانست با uuid دلخواه
        // ظرفیتِ ساختگی ببیند.
        $excludeId   = null;
        $excludeUuid = trim((string) $request->query->get('exclude_appointment_uuid', ''));
        if ($excludeUuid !== '') {
            $excluded = $this->appointmentRepo->findByUuid($excludeUuid);
            $actor    = $this->getUser();
            if ($excluded === null || !$actor instanceof User || !$this->canManage($excluded, $actor)) {
                return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403, 'exclude_appointment_uuid');
            }
            if ($excluded->getDoctor()->getId() !== $doctor->getId()) {
                return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوبت انتخابی به این پزشک تعلق ندارد', 422, 'exclude_appointment_uuid');
            }
            $excludeId = $excluded->getId();
        }

        return $this->success([
            'doctor_uuid'            => $doctorUuid,
            'date'                   => $date,
            'total_duration_minutes' => $duration->totalMinutes,
            'buffer_minutes'         => $duration->bufferMinutes,
            'clinic_uuid'            => $clinic?->getUuid(),
            'start_times'            => $this->slotCalculator->getServiceStartTimes(
                $doctor,
                $date,
                $duration->totalMinutes,
                $clinic,
                $this->isManagementContext($request, $doctor, $clinic),
                $excludeId,
            ),
        ]);
    }

    /**
     * عمومی: روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی.
     * سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی.
     *
     * GET /api/v1/appointment-booking-services/{doctorUuid}
     */
    #[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
    public function bookingServices(string $doctorUuid, Request $request): JsonResponse
    {
        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        $clinic   = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        $meta     = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;

        return $this->success([
            'doctor_uuid'    => $doctorUuid,
            'clinic_uuid'    => $clinic?->getUuid(),
            'booking_mode'   => $meta['booking_mode'],
            'buffer_minutes' => (int) $meta['buffer_minutes'],
            'services'       => $this->bookableServices($doctor, $clinic),
        ]);
    }

    /**
     * عمومی: همهٔ محل‌های نوبت‌دهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ
     * فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهان‌کردن بقیه یعنی حذف
     * بخشی از ظرفیت واقعی پزشک.
     *
     * GET /api/v1/appointment-booking-locations/{doctorUuid}
     */
    #[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
    public function bookingLocations(string $doctorUuid, Request $request): JsonResponse
    {
        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        $date = trim((string) $request->query->get('date', ''));
        if ($date !== '' && !$this->isCalendarDate($date)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
        }

        $locations = [];
        foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
            $clinic    = $schedule->getClinic();
            $addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());

            // محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
            if ($addresses === []) {
                continue;
            }

            $byId = [];
            foreach ($addresses as $a) {
                $byId[(int) $a->getId()] = $a;
            }

            // و برنامه‌ای که هیچ شیفتش روی آدرس‌های همین محیط ننشسته، قابل رزرو نیست.
            $hours = $this->openingHours($schedule, $byId);
            if ($hours === []) {
                continue;
            }

            $meta          = $schedule->getMeta();
            $address       = $byId[$hours[0]['location_id']] ?? $addresses[0];
            $forManagement = $this->isManagementContext($request, $doctor, $clinic);

            $locations[] = [
                'location_uuid'     => $address->getUuid(),
                'type'              => $clinic === null ? 'personal' : 'clinic',
                'title'             => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'),
                'address'           => $address->getAddress(),
                'clinic_uuid'       => $clinic?->getUuid(),
                'booking_mode'      => $meta['booking_mode'],
                'buffer_minutes'    => (int) $meta['buffer_minutes'],
                'opening_hours'     => $hours,
                'services'          => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
                    ? $this->bookableServices($doctor, $clinic)
                    : [],
                'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement),
                'available_on_date' => $date === ''
                    ? null
                    : $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [],
            ];
        }

        // پیش‌فرضِ سایت = زودترین نوبت آزاد؛ محل‌های بدون ظرفیت به انتها می‌روند.
        usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX));

        return $this->success([
            'doctor_uuid'       => $doctorUuid,
            'date'              => $date !== '' ? $date : null,
            'booking_locations' => $locations,
        ]);
    }

    #[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
    public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
    {
        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        $year  = (int) $request->query->get('year');
        $month = (int) $request->query->get('month');
        if ($year < 1970 || $month < 1 || $month > 12) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
        }

        $clinic        = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
        $forManagement = $this->isManagementContext($request, $doctor, $clinic);
        $daysInMonth   = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));

        $disabled = [];
        $enabled  = [];
        for ($day = 1; $day <= $daysInMonth; $day++) {
            $date = sprintf('%04d-%02d-%02d', $year, $month, $day);
            if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic, $forManagement)) {
                $enabled[] = $date;
            } else {
                $disabled[] = $date;
            }
        }

        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        $meta     = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;

        return $this->success([
            'year'                   => $year,
            'clinic_uuid'            => $clinic?->getUuid(),
            'month'                  => $month,
            'disabled_dates'         => $disabled,
            'enabled_dates'          => $enabled,
            'online_booking_enabled' => (bool) $meta['online_booking_enabled'],
            'booking_window'         => [
                'value' => (int) $meta['booking_window_value'],
                'unit'  => $meta['booking_window_unit'],
            ],
        ]);
    }

    // ── Authenticated: book / manage ─────────────────────────────────────────

    #[OA\Post(
        path: '/api/v1/appointment',
        summary: 'Book a new appointment',
        security: [['bearerAuth' => []]],
        requestBody: new OA\RequestBody(
            required: true,
            content: new OA\JsonContent(
                required: ['doctor_uuid', 'slot_start', 'slot_end'],
                properties: [
                    new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
                    new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'),
                    new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'),
                    new OA\Property(property: 'note', type: 'string'),
                ]
            )
        ),
        responses: [
            new OA\Response(
                response: 201,
                description: 'Appointment booked successfully',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(response: 401, description: 'Unauthenticated'),
            new OA\Response(response: 404, description: 'Doctor not found'),
            new OA\Response(response: 409, description: 'Slot already taken'),
            new OA\Response(response: 422, description: 'Validation error'),
        ]
    )]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    #[Route('/api/v1/appointment', methods: ['POST'])]
    public function book(Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $data       = json_decode($request->getContent(), true) ?? [];
        $doctorUuid = trim($data['doctor_uuid'] ?? '');
        $slotStart  = (int) ($data['slot_start'] ?? 0);
        $slotEnd    = (int) ($data['slot_end'] ?? 0);
        $clinicUuid = $data['clinic_uuid'] ?? null;

        // حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده،
        // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود).
        $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
        $serviceItem  = null;
        if (!empty($serviceUuids)) {
            $totalMinutes = 0;
            foreach ($serviceUuids as $u) {
                $item = $this->itemRepo->findByUuid($u);
                if ($item === null) {
                    return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
                }
                if (!$item->isBookable()) {
                    return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
                }
                if (($item->getDurationMinutes() ?? 0) <= 0) {
                    return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
                }
                $totalMinutes += (int) $item->getDurationMinutes();
                $serviceItem ??= $item;
            }
            $slotEnd = $slotStart + $totalMinutes * 60;
        }

        if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
        }

        if ($slotStart < time()) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
        }

        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        $bookingClinic = $this->bookingClinic($doctor, $clinicUuid);

        // سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت می‌شود؛ وگرنه بیمار
        // می‌توانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند.
        if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) {
            return $err;
        }

        $forSelf = (bool) ($data['for_self'] ?? true);

        // کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
        $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
        // فرانت‌اند male/female می‌فرستد؛ به فرمِ متعارفِ بک‌اند (man/woman) نگاشت می‌شود.
        $gender = match (strtolower(trim((string) ($data['patient_gender'] ?? '')))) {
            'man', 'male'     => 'man',
            'woman', 'female' => 'woman',
            default           => '',
        };

        if ($nationalCode === '') {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
        }
        if (!InputValidator::isValidIranNationalCode($nationalCode)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code');
        }
        if ($gender === '') {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'جنسیت بیمار الزامی است', 422, 'patient_gender');
        }

        $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
        $appointment->setPatientNationalCode($nationalCode);
        $appointment->setPatientGender($gender);
        if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
        if (isset($data['note'])) $appointment->setNote($data['note']);

        // نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی
        // پرداخت دوباره از payment.frontend_address محاسبه می‌شود — این فقط ثبتِ لحظه‌ی رزرو است.
        $bookingCtx = $this->domainResolver->resolve($request->headers->get('origin'));
        if ($bookingCtx->representationId() !== null) {
            $appointment->setBookingRepresentationId($bookingCtx->representationId());
        }

        // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id).
        $appointment->setClinic($bookingClinic);
        $appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
        $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
        if ($locationId !== null) {
            $appointment->setAddressId($locationId);
        }

        if ($forSelf) {
            $appointment->setPatientName($user->getRealName());
            $appointment->setPatientMobile($user->getMobileNumber());
        } else {
            $patientName   = trim($data['patient_name'] ?? '');
            $patientMobile = trim($data['patient_mobile'] ?? '');
            if ($patientName === '' || $patientMobile === '') {
                return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
            }
            $appointment->setPatientName($patientName);
            $appointment->setPatientMobile($patientMobile);
            $appointment->setPatientReason($data['patient_reason'] ?? null);
        }

        $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);

        try {
            $this->appointmentRepo->bookAtomically($appointment);
        } catch (SlotTakenException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
        }

        return $this->success(['data' => $appointment->toArray()], 201);
    }

    #[OA\Get(
        path: '/api/v1/appointment/{uuid}',
        summary: 'Get a single appointment by UUID',
        security: [['bearerAuth' => []]],
        parameters: [
            new OA\Parameter(
                name: 'uuid',
                in: 'path',
                required: true,
                schema: new OA\Schema(type: 'string', format: 'uuid')
            ),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: 'Appointment returned',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(response: 401, description: 'Unauthenticated'),
            new OA\Response(response: 403, description: 'Access denied'),
            new OA\Response(response: 404, description: 'Appointment not found'),
        ]
    )]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    #[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
    public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }

        if (!$this->canView($appointment, $user)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        return $this->success(['data' => $appointment->toArray()]);
    }

    #[OA\Get(
        path: '/api/v1/appointments/doctor/{doctorUuid}',
        summary: 'List appointments for a specific doctor',
        security: [['bearerAuth' => []]],
        parameters: [
            new OA\Parameter(
                name: 'doctorUuid',
                in: 'path',
                required: true,
                schema: new OA\Schema(type: 'string', format: 'uuid')
            ),
            new OA\Parameter(
                name: 'status',
                in: 'query',
                required: false,
                description: 'Filter by appointment status',
                schema: new OA\Schema(type: 'string', example: 'pending')
            ),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: 'Appointment list returned',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(
                            property: 'data',
                            type: 'array',
                            items: new OA\Items(type: 'object', description: 'Appointment object')
                        ),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(response: 401, description: 'Unauthenticated'),
            new OA\Response(response: 403, description: 'Access denied'),
            new OA\Response(response: 404, description: 'Doctor not found'),
        ]
    )]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    #[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
    public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $doctor = $this->doctorRepo->findByUuid($doctorUuid);
        if ($doctor === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
        }

        $isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');

        // مدیر کلینیک لیست پزشک عضو را می‌بیند، ولی فقط نوبت‌های همان کلینیک —
        // نوبت‌های مطب شخصی پزشک به کلینیک نشت نمی‌کند.
        $scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor);
        if (!$isOwner && $scopeClinic === null) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        // بدون هیچ پارامتر فیلتر/صفحه‌بندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ
        // می‌شود تا کلاینت‌های موجود نشکنند. با هر فیلتری پاسخ صفحه‌بندی‌شده می‌آید.
        $filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit'];
        $isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k));

        if (!$isFiltered) {
            $status       = $request->query->get('status');
            $appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);

            return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
        }

        // هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته می‌شود؛ سینتکس
        // دوم بدون براکت در Symfony به رشته تبدیل می‌شود و all() استثنا می‌دهد.
        $rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : [];
        $statuses    = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses);
        if ($statuses === [] && $request->query->get('status')) {
            $statuses = [$request->query->get('status')];
        }

        $page  = max(1, (int) $request->query->get('page', 1));
        $limit = min(100, max(1, (int) $request->query->get('limit', 20)));

        $result = $this->appointmentRepo->searchByDoctor(
            doctor:      $doctor,
            statuses:    array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')),
            clinic:      $scopeClinic,
            from:        $request->query->has('from') ? (int) $request->query->get('from') : null,
            to:          $request->query->has('to')   ? (int) $request->query->get('to')   : null,
            query:       $request->query->get('q'),
            serviceUuid: $request->query->get('service_uuid'),
            page:        $page,
            limit:       $limit,
        );

        return $this->paginated(
            array_map(fn(Appointment $a) => $a->toArray(), $result['items']),
            $result['total'],
            $page,
            $limit,
        );
    }

    #[OA\Get(
        path: '/api/v1/appointments/user',
        summary: 'List appointments for the authenticated user',
        security: [['bearerAuth' => []]],
        parameters: [
            new OA\Parameter(
                name: 'status',
                in: 'query',
                required: false,
                description: 'Filter by appointment status',
                schema: new OA\Schema(type: 'string', example: 'pending')
            ),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: 'Appointment list returned',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(
                            property: 'data',
                            type: 'array',
                            items: new OA\Items(type: 'object', description: 'Appointment object')
                        ),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(response: 401, description: 'Unauthenticated'),
        ]
    )]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    #[Route('/api/v1/appointments/user', methods: ['GET'])]
    public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $status       = $request->query->get('status');
        $appointments = $this->appointmentRepo->findByUser($user, $status);

        return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
    }

    private function canView(Appointment $a, User $user): bool
    {
        return $this->accessChecker->canView($a, $user);
    }

    private function canManage(Appointment $a, User $user): bool
    {
        return $this->accessChecker->canManage($a, $user);
    }

    /**
     * محلِ نوبت‌دهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی
     * که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای
     * اشتباه.
     */
    private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
    {
        return $this->bookingContext->resolve($doctor, $clinicUuid);
    }

    /**
     * آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟
     * اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز،
     * توگلِ نوبت‌دهی آنلاین دور زده می‌شود. در غیر این‌صورت مثل رزرو عمومی رفتار می‌شود
     * (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند.
     */
    private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
    {
        if ($request->query->get('management') !== '1') {
            return false;
        }
        $user = $this->getUser();
        if (!$user instanceof User) {
            return false;
        }

        return $this->accessChecker->canManageContext($user, $doctor, $clinic);
    }

    private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
    {
        [$type, $id] = $clinic !== null
            ? ['clinic', $clinic->getId()]
            : ['doctor', $doctor->getId()];

        foreach ($serviceUuids as $uuid) {
            $section = $this->itemRepo->findByUuid($uuid)?->getSection();
            if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) {
                return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
            }
        }

        return null;
    }

    /**
     * تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99»
     * الگو را پاس می‌کند ولی روزی نیست.
     */
    private function isCalendarDate(string $date): bool
    {
        $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);

        return $parsed !== false && $parsed->format('Y-m-d') === $date;
    }

    /** @return array<int, array<string, mixed>> */
    private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
    {
        [$type, $id] = $clinic !== null
            ? ['clinic', $clinic->getId()]
            : ['doctor', $doctor->getId()];

        return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array {
            $section = $i->getSection();
            return [
                'uuid'             => $i->getUuid(),
                'name'             => $i->getName(),
                'duration_minutes' => $i->getDurationMinutes(),
                'price_rials'      => $i->getPriceRials(),
                'service_section'  => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
            ];
        }, $this->itemRepo->findBookableByEntity($type, $id));
    }

    /**
     * شیفت‌های فعال هفته به‌صورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به
     * openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است.
     *
     * فقط شیفت‌هایی برمی‌گردند که آدرسشان در $allowedAddressIds باشد.
     *
     * @param array<int, \App\Doctor\Entity\DoctorAddress> $allowedAddressIds
     *
     * @return array<int, array{day: string, day_index: int, location_id: int, opens: string, closes: string}>
     */
    private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
    {
        $hours = [];

        foreach ($schedule->getDaySchedule() as $dayIndex => $day) {
            $dayName = WeeklySchedule::DAYS[(int) $dayIndex] ?? null;
            if ($dayName === null) {
                continue;
            }

            foreach (($day['sessions'] ?? []) as $session) {
                if (!($session['active'] ?? false)) {
                    continue;
                }

                // شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره می‌کند،
                // قابل رزرو نیست و نباید ساعت کاری تولید کند.
                $locationId = (int) ($session['location_id'] ?? 0);
                if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
                    continue;
                }

                $opens  = $session['start_time'] ?? null;
                $closes = $session['end_time'] ?? null;
                if ($opens === null || $closes === null) {
                    continue;
                }

                $hours[] = [
                    'day'         => ucfirst($dayName),
                    'day_index'   => (int) $dayIndex,
                    'location_id' => $locationId,
                    'opens'       => $opens,
                    'closes'      => $closes,
                ];
            }
        }

        return $hours;
    }


    #[OA\Patch(
        path: '/api/v1/appointment/{uuid}/status',
        summary: 'Update the status of an appointment',
        security: [['bearerAuth' => []]],
        requestBody: new OA\RequestBody(
            required: true,
            content: new OA\JsonContent(
                required: ['status'],
                properties: [
                    new OA\Property(property: 'status', type: 'string', example: 'confirmed'),
                    new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'),
                ]
            )
        ),
        parameters: [
            new OA\Parameter(
                name: 'uuid',
                in: 'path',
                required: true,
                schema: new OA\Schema(type: 'string', format: 'uuid')
            ),
        ],
        responses: [
            new OA\Response(
                response: 200,
                description: 'Appointment status updated',
                content: new OA\JsonContent(
                    properties: [
                        new OA\Property(property: 'success', type: 'boolean', example: true),
                        new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'),
                        new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
                    ]
                )
            ),
            new OA\Response(response: 401, description: 'Unauthenticated'),
            new OA\Response(response: 403, description: 'Access denied'),
            new OA\Response(response: 404, description: 'Appointment not found'),
            new OA\Response(response: 409, description: 'Optimistic lock conflict'),
            new OA\Response(response: 422, description: 'Invalid status transition'),
        ]
    )]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    #[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
    public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }

        $data      = json_decode($request->getContent(), true) ?? [];
        $newStatus = trim($data['status'] ?? '');
        $version   = (int) ($data['version'] ?? $appointment->getVersion());

        // لغو مجوز جداگانه دارد: منشی به‌صورت پیش‌فرض اجازهٔ لغو ندارد ولی وضعیت‌های
        // دیگر را تغییر می‌دهد.
        $action = in_array($newStatus, self::CANCEL_STATUSES, true)
            ? AppointmentAccessChecker::ACTION_CANCEL
            : AppointmentAccessChecker::ACTION_UPDATE_STATUS;

        if (!$this->accessChecker->can($appointment, $user, $action)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        if (!$appointment->canTransitionTo($newStatus)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
                'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
            ), 422);
        }

        $appointment->transitionTo($newStatus);

        try {
            $this->appointmentRepo->saveWithLock($appointment, $version);
        } catch (OptimisticLockException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
        }

        if ($newStatus === Appointment::STATUS_CONFIRMED) {
            $this->appointmentConfirmation->onConfirmed($appointment);
        }

        if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
            $reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
            $this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
        }

        if ($newStatus === Appointment::STATUS_COMPLETED) {
            $this->recordCompletion($appointment);
        }

        return $this->success(['data' => $appointment->toArray()]);
    }

    /**
     * قطعی‌کردن نوبت به‌همراه پرداخت — «ثبت‌شده» → «قطعی».
     *
     * یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویس‌های نوبت،
     * و ثبت پرداخت‌های کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچ‌کدام
     * ثبت نمی‌شوند.
     */
    #[OA\Post(
        path: '/api/v1/appointment/{uuid}/confirm',
        summary: 'Confirm an appointment and register its payments on the patient case file',
        security: [['bearerAuth' => []]],
        responses: [
            new OA\Response(response: 200, description: 'Appointment confirmed'),
            new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'),
            new OA\Response(response: 404, description: 'Appointment not found'),
            new OA\Response(response: 409, description: 'Version conflict'),
            new OA\Response(response: 422, description: 'Invalid transition or payment'),
        ]
    )]
    #[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }

        if (!$this->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        $data    = json_decode($request->getContent(), true) ?? [];
        $version = (int) ($data['version'] ?? $appointment->getVersion());

        if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
                'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED
            ), 422);
        }

        // پرداختِ چندروشی (split): هر ردیف یک روش + مبلغ، و به‌اختیار جزئیاتِ روش
        // (uuid کارت‌خوان/حساب بانکیِ ثبت‌شده + شناسه تراکنش).
        $payments = [];
        foreach ((array) ($data['payments'] ?? []) as $row) {
            $method = trim((string) ($row['method'] ?? ''));
            $amount = (int) ($row['amount_rials'] ?? 0);
            if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) {
                return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method');
            }
            if ($amount <= 0) {
                return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials');
            }
            $methodUuid = trim((string) ($row['payment_method_uuid'] ?? ''));
            $reference  = trim((string) ($row['reference'] ?? ''));
            $payments[] = [
                'method'              => $method,
                'amount_rials'        => $amount,
                'payment_method_uuid' => $methodUuid !== '' ? mb_substr($methodUuid, 0, 36) : null,
                'reference'           => $reference !== '' ? mb_substr($reference, 0, 255) : null,
            ];
        }

        // انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با
        // همان بیمه محاسبه شوند.
        $this->appointmentInsurance->apply($appointment, $data);

        try {
            $session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
        } catch (OptimisticLockException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
        }

        return $this->success([
            'appointment' => $appointment->toArray(),
            'session'     => $session === null ? null : [
                'uuid'              => $session->getUuid(),
                'visit_price_rials' => $session->getVisitPriceRials(),
                'services_total_rials' => $session->getServicesTotalRials(),
                'final_price_rials' => $session->getFinalPriceRials(),
                'discount_rials'    => $session->getDiscountRials(),
                'paid_total_rials'  => $session->getPaidTotalRials(),
                'remaining_rials'   => $session->getRemainingRials(),
                'is_paid'           => $session->getRemainingRials() === 0,
                // تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده.
                'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
                'insurance_base_id'          => $session->getInsuranceBaseId(),
                'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
                'gross_total_rials'          => $session->getGrossTotalRials(),
                'base_insurance_rials'       => $session->getBaseInsuranceRials(),
                'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
                'patient_share_rials'        => $session->getPatientShareRials(),
            ],
        ]);
    }

    /**
     * General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی).
     * All fields optional; only what is present in the body changes. Slot moves
     * go through rescheduleTo so active_slot_key stays consistent. Optimistic
     * lock via `version` like the status endpoint.
     */
    #[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }

        if (!$this->canManage($appointment, $user)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        $data    = json_decode($request->getContent(), true) ?? [];
        $version = (int) ($data['version'] ?? $appointment->getVersion());

        // status درون‌خطی نباید گیت لغو را دور بزند.
        if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true)
            && !$this->accessChecker->canCancel($appointment, $user)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        // ── حالت نوبت‌دهی سرویسی: مدت، داده است نه ورودی ──────────────────────────
        // سرویس‌ها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آن‌ها وابسته است.
        // در حالت اسلاتی هیچ‌کدام از این خطوط اجرا نمی‌شود و رفتار بیت‌به‌بیت همان می‌ماند.
        $serviceMode  = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic());
        $hasServiceSet = array_key_exists('service_item_uuids', $data);
        $duration     = null;

        if ($serviceMode) {
            $requestedUuids = $hasServiceSet
                ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
                : $appointment->currentServiceUuids();

            if ($requestedUuids !== []) {
                $duration = $this->serviceCalculator->calculate(
                    $appointment->getDoctor(),
                    $appointment->getClinic(),
                    $requestedUuids,
                    (array) ($data['durations'] ?? []),
                    // سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی
                    // افزودن سرویس غیرفعالِ تازه رد می‌شود.
                    allowInactive: !$hasServiceSet,
                );
            }
        }

        // Slot move / reserve toggle — both times together, or neither.
        $hasStart = array_key_exists('slot_start', $data);
        $hasEnd   = array_key_exists('slot_end', $data);
        if ($hasStart !== $hasEnd) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start');
        }
        if ($hasStart || array_key_exists('is_reserve', $data)) {
            $newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart();
            $newEnd   = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd();
            if ($newEnd < $newStart) {
                return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end');
            }
            $isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null;
            $movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false;

            // نوبتِ زمان‌دارِ سرویسی نمی‌تواند مدت دلخواه بگیرد. نوبت رزرو معاف است:
            // slot_start == slot_end دارد و بازه‌ای اشغال نمی‌کند.
            if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) {
                return $this->error(
                    ErrorCodes::ERR_APPOINTMENT_003,
                    sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
                    422,
                    'slot_end',
                );
            }

            if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd())
                && $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) {
                return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
            }
            // rescheduleTo() خودش refreshActiveSlotKey() را صدا می‌زند، پس تبدیل رزرو به
            // نوبت زمان‌دار (is_reserve: false) کلید یکتایی را بازتولید می‌کند.
            $appointment->rescheduleTo($newStart, $newEnd, $isReserve);
        }

        if ($duration !== null) {
            if ($hasServiceSet) {
                $appointment->replaceServiceItems($duration->serviceItems);
            }
            $appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
        }

        // Workflow relations — empty string clears, uuid assigns, unknown → 422.
        // `service_item_uuid` تکی وقتی نادیده گرفته می‌شود که فهرست کامل آمده باشد،
        // وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد.
        foreach ([
            'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
            'service_item_uuid'    => [$this->itemRepo, 'setServiceItem', 'سرویس'],
            'staff_uuid'           => [$this->staffRepo, 'setStaff', 'پرسنل'],
        ] as $key => [$repo, $setter, $label]) {
            if ($key === 'service_item_uuid' && $hasServiceSet) {
                continue;
            }
            if (!array_key_exists($key, $data)) {
                continue;
            }
            $value = trim((string) ($data[$key] ?? ''));
            if ($value === '') {
                $appointment->$setter(null);
                continue;
            }
            $entity = $repo->findByUuid($value);
            if ($entity === null) {
                return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key);
            }
            $appointment->$setter($entity);
        }

        if (array_key_exists('deposit_required', $data)) {
            $appointment->setDepositRequired((bool) $data['deposit_required']);
        }
        if (array_key_exists('deposit_amount_rials', $data)) {
            $appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null);
        }
        if (array_key_exists('note', $data)) {
            $appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null);
        }
        $this->appointmentInsurance->apply($appointment, $data);
        // جایگزینی نوبت — swap the person occupying the slot.
        if (array_key_exists('patient_name', $data)) {
            $appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null);
        }
        if (array_key_exists('patient_mobile', $data)) {
            $appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null);
        }

        // Optional status transition, same rules as the dedicated endpoint.
        $newStatus     = trim((string) ($data['status'] ?? ''));
        $cancelledTo   = null;
        $completed     = false;
        if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
            if (!$appointment->canTransitionTo($newStatus)) {
                return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
                    'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
                ), 422);
            }
            $appointment->transitionTo($newStatus);
            if ($newStatus === Appointment::STATUS_CONFIRMED) {
                $this->appointmentConfirmation->onConfirmed($appointment);
            }
            if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
                $cancelledTo = $newStatus;
            }
            $completed = $newStatus === Appointment::STATUS_COMPLETED;
        }

        try {
            $this->appointmentRepo->saveWithLock($appointment, $version);
        } catch (OptimisticLockException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
        } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
            // race backstop: someone grabbed the slot between the pre-check and the flush
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
        }

        if ($cancelledTo !== null) {
            $reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
            $this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
        }

        if ($completed) {
            $this->recordCompletion($appointment);
        }

        return $this->success(['data' => $appointment->toArray()]);
    }

    /**
     * جابه‌جایی سرویس‌آگاه — فقط حالت نوبت‌دهی سرویسی.
     *
     * کلاینت مدت نمی‌فرستد: `start` می‌دهد و سرور مدت را از سرویس‌های نوبت (یا فهرست
     * صریحِ درخواست) حساب می‌کند. این تفاوت با `PATCH` است که کلاینت باید `slot_end`
     * درست را از قبل بداند.
     *
     * POST /api/v1/appointment/{uuid}/service-reschedule
     */
    #[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }
        if (!$this->canManage($appointment, $user)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        $data  = json_decode($request->getContent(), true) ?? [];
        $start = (int) ($data['start'] ?? 0);
        if ($start <= 0) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start');
        }

        $serviceUuids = array_key_exists('service_item_uuids', $data)
            ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
            : null;

        try {
            $duration = $this->rescheduleService->reschedule(
                $appointment,
                $start,
                $serviceUuids,
                (array) ($data['durations'] ?? []),
                $this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()),
                array_key_exists('version', $data) ? (int) $data['version'] : null,
            );
        } catch (OptimisticLockException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
        } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
            return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start');
        }

        return $this->success([
            'uuid'                   => $appointment->getUuid(),
            'slot_start'             => $appointment->getSlotStart(),
            'slot_end'               => $appointment->getSlotEnd(),
            'total_duration_minutes' => $duration->totalMinutes,
            'buffer_minutes'         => $duration->bufferMinutes,
            'warnings'               => $duration->warnings,
        ]);
    }

    // ── Timeline: رویدادهای یک نوبت ───────────────────────────────────────────

    #[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function events(string $uuid, #[CurrentUser] User $user): JsonResponse
    {
        $appointment = $this->appointmentRepo->findByUuid($uuid);
        if ($appointment === null) {
            return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
        }
        if (!$this->canView($appointment, $user)) {
            return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
        }

        return $this->success($this->eventRepo->findByAppointmentUuid($uuid));
    }
}
