<?php

namespace App\Policy\Service;

use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\ValueObject\PolicyOutcome;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\UserProfile\Entity\UserProfile;
use Doctrine\ORM\EntityManagerInterface;

/**
 * دو دستهٔ قانون که به **بیمار** وابسته‌اند، نه به سرویس: «صلاحیت» و «فاصله».
 *
 * جای اجرایشان لحظهٔ رزرو موقت است نه ثبت نهایی: کاربری که ده دقیقه صندلی گرفته و
 * بعد می‌شنود «شما واجد شرایط نیستید» هم وقت خودش را تلف کرده هم صندلی را.
 *
 * حقایق از پروفایل و تاریخچهٔ بیمار خوانده می‌شوند نه از بدنهٔ درخواست — با یک
 * استثنا: `has_parental_consent` چیزی است که اپراتور همان لحظه تأیید می‌کند و
 * جایی برای ذخیره ندارد.
 */
final class BookingPolicyGuard
{
    public function __construct(
        private readonly \App\Policy\Engine\EligibilityPolicyEngine         $eligibilityPolicies,
        private readonly \App\Policy\Engine\SpacingPolicyEngine         $spacingPolicies,
        private readonly EntityManagerInterface $em,
    ) {}

    /**
     * @param ServiceItem[]        $items
     * @param array<string, mixed> $extraFacts حقایقی که فقط در همین درخواست وجود دارند
     *
     * @throws AppException ۴۲۲ اگر قانونی این بیمار را ممنوع کند
     */
    public function assertEligible(
        User $patient,
        ServiceItem $service,
        array $items,
        DoctorAddress $address,
        array $extraFacts = [],
        ?int $at = null,
    ): PolicyOutcome {
        $at = $at ?? time();

        $outcome = $this->eligibilityPolicies->evaluate(
            $address->tenantEntityType(),
            $address->tenantEntityId(),
            $this->patientFacts($patient, $address, $at) + $extraFacts + ['item_count' => count($items) + 1],
            $address,
            $service,
            $at,
        );

        if ($outcome->isForbidden()) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                implode(' ', $outcome->forbidReasons),
                422,
            );
        }

        // `require_flag` ممنوعیت نیست، شرط است: تا وقتی اپراتور آن پرچم را نفرستاده
        // درخواست ناقص است، و بعد از فرستادنش قانون راضی است.
        $missing = array_values(array_filter(
            (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []),
            static fn (string $flag): bool => empty($extraFacts[$flag]),
        ));

        if ($missing !== []) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_002,
                sprintf('برای این نوبت تأیید %s الزامی است', implode('، ', $missing)),
                422,
                $missing[0],
            );
        }

        return $outcome;
    }

    /**
     * حداقل فاصله تا نوبت قبلیِ **همان دسته** — بند «فاصلهٔ بین جلسات».
     *
     * مبنا نوبت قبلی است نه نوبت بعدی: قانون می‌گوید بعد از هر جلسه چقدر باید صبر
     * کرد، پس رزرو آینده‌ای که هنوز انجام نشده معیار نیست.
     *
     * @throws AppException ۴۲۲ اگر فاصله کافی نباشد
     */
    public function assertSpacing(
        User $patient,
        ServiceItem $service,
        DoctorAddress $address,
        int $startsAt,
        ?int $at = null,
    ): void {
        $at = $at ?? time();

        $outcome = $this->spacingPolicies->evaluate(
            $address->tenantEntityType(),
            $address->tenantEntityId(),
            [
                'service_uuid'     => $service->getUuid(),
                'catalog_category' => $service->getCatalogCategory()?->getUuid(),
            ],
            $address,
            $service,
            $at,
        );

        $minDays = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0);

        if ($minDays <= 0) {
            return;
        }

        $last = $this->lastAppointmentAt($patient, $service, $startsAt);

        if ($last === null) {
            return;
        }

        $gapDays = (int) floor(($startsAt - $last) / 86400);

        if ($gapDays < $minDays) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                sprintf('بین دو جلسهٔ این خدمت باید حداقل %d روز فاصله باشد', $minDays),
                422,
            );
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function patientFacts(User $patient, DoctorAddress $address, int $at): array
    {
        $profile = $this->em->getRepository(UserProfile::class)->findOneBy(['user' => $patient]);

        return [
            'patient_age'    => $this->ageOf($profile?->getDateOfBirth(), $at),
            'patient_gender' => $profile?->getGender(),
            'patient_tags'   => [],
            'visit_count'    => $this->visitCount($patient, $address),
            // عملگر `days_since` روی همین می‌نشیند: «بیش از N روز از آخرین ویزیت گذشته».
            // صفر یعنی «هرگز» و هر شرط زمانی را رد می‌کند.
            'last_visit_at'  => $this->lastVisitAt($patient, $address),
        ];
    }

    /** سن با سال میانگین گریگوری حساب می‌شود؛ اختلافش با شمسی در مرز سن صفر است. */
    private function ageOf(?int $dateOfBirth, int $at): ?int
    {
        if ($dateOfBirth === null || $dateOfBirth <= 0) {
            return null;
        }

        return (int) floor(($at - $dateOfBirth) / 31556952);
    }

    private function visitCount(User $patient, DoctorAddress $address): int
    {
        return (int) $this->em->createQueryBuilder()
            ->select('COUNT(a.id)')
            ->from(Appointment::class, 'a')
            ->where('a.user = :user')
            ->andWhere('a.status = :status')
            ->setParameter('user', $patient)
            ->setParameter('status', Appointment::STATUS_COMPLETED)
            ->getQuery()
            ->getSingleScalarResult();
    }

    /**
     * آخرین نوبتِ گذشتهٔ بیمار از همان دستهٔ کاتالوگ — یا از همان سرویس اگر دسته ندارد.
     */
    /** آخرین ویزیت بیمار در این محیط، بدون قید سرویس — `0` یعنی هرگز. */
    private function lastVisitAt(User $patient, DoctorAddress $address): int
    {
        return (int) $this->em->createQueryBuilder()
            ->select('MAX(a.slotStart)')
            ->from(\App\Appointment\Entity\Appointment::class, 'a')
            ->where('a.user = :patient')
            ->andWhere('a.entityType = :type')
            ->andWhere('a.entityId = :id')
            ->andWhere('a.status IN (:done)')
            ->setParameter('patient', $patient)
            ->setParameter('type', $address->tenantEntityType())
            ->setParameter('id', $address->tenantEntityId())
            ->setParameter('done', [
                \App\Appointment\Entity\Appointment::STATUS_COMPLETED,
                \App\Appointment\Entity\Appointment::STATUS_CONFIRMED,
            ])
            ->getQuery()
            ->getSingleScalarResult();
    }

    private function lastAppointmentAt(User $patient, ServiceItem $service, int $before): ?int
    {
        $qb = $this->em->createQueryBuilder()
            ->select('MAX(a.slotStart)')
            ->from(Appointment::class, 'a')
            ->join('a.serviceItem', 'si')
            ->where('a.user = :user')
            ->andWhere('a.slotStart < :before')
            ->andWhere('a.status NOT IN (:dead)')
            ->setParameter('user', $patient)
            ->setParameter('before', $before)
            ->setParameter('dead', [
                Appointment::STATUS_CANCELLED_BY_DOCTOR,
                Appointment::STATUS_CANCELLED_BY_USER,
                Appointment::STATUS_EXPIRED,
            ]);

        $category = $service->getCatalogCategory();

        if ($category !== null) {
            $qb->andWhere('si.catalogCategory = :category')->setParameter('category', $category);
        } else {
            $qb->andWhere('si = :service')->setParameter('service', $service);
        }

        $result = $qb->getQuery()->getSingleScalarResult();

        return $result === null ? null : (int) $result;
    }
}
