<?php

namespace App\Appointment\Plan\Service;

use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Appointment\Plan\ValueObject\PlannedRequirement;
use App\Appointment\Plan\ValueObject\PlannedSegment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Service\DurationCalculator;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourceTypeRepository;
use App\Shared\Constant\ErrorCodes;
use App\Policy\Entity\Policy;
use App\Policy\Service\PolicySchema;
use App\Policy\Engine\ResourcePolicyEngine;
use App\Policy\Engine\TimingPolicyEngine;
use App\Shared\Exception\AppException;

/**
 * از (سرویس، آیتم‌های انتخاب‌شده، شعبه، بیمار) یک **برنامهٔ نوبت** می‌سازد.
 *
 * هیچ زمان مطلقی و هیچ منبع مشخصی تعیین نمی‌کند — آن کارِ تسک ۰۶ است. اینجا فقط
 * شکل نوبت ساخته می‌شود: چند بخش، هرکدام چقدر، و هرکدام چه منبعی می‌خواهد.
 */
final class AppointmentPlanBuilder
{
    public function __construct(
        private readonly TimingPolicyEngine            $timingPolicies,
        private readonly ResourcePolicyEngine            $resourcePolicies,
        private readonly SegmentTemplateRepository $templates,
        private readonly ClinicResourceRepository  $resources,
        private readonly ResourceTypeRepository    $types,
        private readonly DurationCalculator        $durations,
    ) {}

    /**
     * @param ServiceItem[] $selectedItems آیتم‌هایی که بیمار انتخاب کرده
     * @param string|null   $patientGender `male` | `female` | null
     */
    public function build(
        ServiceItem $service,
        array $selectedItems,
        DoctorAddress $address,
        ?string $patientGender = null,
    ): AppointmentPlan {
        $itemMinutes = $this->durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]);
        $templates   = $this->gatherTemplates($service, $selectedItems);

        // سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که
        // پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد.
        if ($templates === []) {
            $segments = $this->singleSegment($service, $address, $itemMinutes);
            $offset   = $itemMinutes;

            return $this->finish($service, $selectedItems, $address, $segments, $offset);
        }

        $segments = [];
        $offset   = 0;
        $counts   = $this->mergedCounts($templates);

        foreach ($this->orderedTemplates($templates) as $template) {
            $duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS
                ? $itemMinutes
                : $template->getDurationMinutes();

            if ($duration <= 0) {
                throw new AppException(
                    ErrorCodes::ERR_VALIDATION_001,
                    sprintf('مدت بخش «%s» تعیین نشده است', $template->getName()),
                    422,
                    'duration_minutes',
                );
            }

            $segments[] = new PlannedSegment(
                sequence: $template->getSequence(),
                name: $template->getName(),
                offsetMinutes: $offset,
                durationMinutes: $duration,
                patientPresent: $template->isPatientPresent(),
                mergeable: $template->isMergeable(),
                requirements: $this->planRequirements($template, $address, $patientGender, $counts[$template->getName()] ?? []),
            );

            $offset += $duration;
        }

        return $this->finish($service, $selectedItems, $address, $segments, $offset);
    }

    /**
     * پایان مشترک هر دو مسیر — با الگو و بی‌الگو.
     *
     * قوانین باید روی سرویسِ بی‌الگو هم اجرا شوند: «حداقل ۶۰ دقیقه برای این دسته»
     * ربطی به این ندارد که کلینیک برای آن سرویس بخش تعریف کرده باشد یا نه.
     *
     * @param ServiceItem[]        $selectedItems
     * @param list<PlannedSegment> $segments
     */
    private function finish(
        ServiceItem $service,
        array $selectedItems,
        DoctorAddress $address,
        array $segments,
        int $total,
    ): AppointmentPlan {
        // ── قوانین دستهٔ «زمان» ────────────────────────────────────────────
        // اثرها روی **مجموع** نوبت اعمال می‌شوند نه روی یک بخش: «حداقل ۶۰ دقیقه»
        // یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد.
        $total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total);

        // ── قوانین دستهٔ «منبع» ─────────────────────────────────────────────
        $segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments);

        if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                sprintf('مجموع مدت بخش‌ها (%d دقیقه) از سقف %d دقیقه بیشتر است', $total, SegmentTemplate::MAX_TOTAL_MINUTES),
                422,
                'segments',
            );
        }

        return new AppointmentPlan($segments, $total);
    }

    /**
     * قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع).
     *
     * @param ServiceItem[]        $selectedItems
     * @param list<PlannedSegment> $segments  به‌صورت ارجاع تغییر می‌کند
     */
    private function applyTimingPolicies(
        ServiceItem $service,
        array $selectedItems,
        DoctorAddress $address,
        array &$segments,
        int $total,
    ): int {
        $outcome = $this->timingPolicies->evaluate(
            $address->tenantEntityType(),
            $address->tenantEntityId(),
            [
                'service_uuid'     => $service->getUuid(),
                'item_count'       => count($selectedItems),
                'catalog_category' => $service->getCatalogCategory()?->getUuid(),
            ],
            $address,
            $service,
        );

        if ($outcome->effects === []) {
            return $total;
        }

        $extra   = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0);
        $minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0);
        $target  = max($total + $extra, $minimum);

        if ($target === $total || $segments === []) {
            return $total;
        }

        // مدت اضافه به **آخرین** بخش می‌رود: آفست بخش‌های قبلی نباید عوض شود، وگرنه
        // برنامه‌ای که کاربر تأیید کرده زیر پایش جابه‌جا می‌شود.
        $last  = $segments[count($segments) - 1];
        $grown = $last->durationMinutes + ($target - $total);

        $segments[count($segments) - 1] = new PlannedSegment(
            sequence: $last->sequence,
            name: $last->name,
            offsetMinutes: $last->offsetMinutes,
            durationMinutes: $grown,
            patientPresent: $last->patientPresent,
            mergeable: $last->mergeable,
            requirements: $last->requirements,
        );

        return $target;
    }

    /**
     * الگوهای سرویس اصلی **به‌علاوهٔ** الگوهای آیتم‌های انتخاب‌شده.
     *
     * بدون این، بخش‌هایی که کلینیک روی خودِ ناحیه تعریف کرده بی‌صدا نادیده می‌ماندند و
     * پرچم `mergeable` هرگز کاری نمی‌کرد — چون در یک سرویس، دو بخشِ هم‌نام معنا ندارد.
     *
     * @param ServiceItem[] $selectedItems
     * @return SegmentTemplate[]
     */
    private function gatherTemplates(ServiceItem $service, array $selectedItems): array
    {
        $ids = [(int) $service->getId()];

        foreach ($selectedItems as $item) {
            $ids[] = (int) $item->getId();
        }

        $ids = array_values(array_unique(array_filter($ids)));

        if (count($ids) === 1) {
            return $this->templates->findForService($service);
        }

        $gathered = [];

        foreach ($this->templates->findForServices($ids) as $forService) {
            foreach ($forService as $template) {
                $gathered[(int) $template->getId()] = $template;
            }
        }

        return array_values($gathered);
    }

    /**
     * بخش‌های `mergeable` هم‌نام یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام
     * می‌شود، ولی «خود لیزر» به‌ازای هر ناحیه طولانی‌تر می‌شود (و آن با
     * `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش).
     *
     * از میان هم‌نام‌ها **طولانی‌ترین** می‌ماند: آماده‌سازیِ دو ناحیه کوتاه‌تر از
     * طولانی‌ترینشان نیست. بخشی که مدتش از آیتم‌ها می‌آید هم یک بار می‌آید حتی اگر
     * `mergeable` نباشد، چون `DurationCalculator` از قبل مجموع همهٔ آیتم‌ها را داده و
     * تکرارش یعنی دوبار شمردن همان زمان.
     *
     * @param SegmentTemplate[] $templates
     * @return SegmentTemplate[]
     */
    private function orderedTemplates(array $templates): array
    {
        $best = [];
        $rest = [];

        foreach ($templates as $template) {
            $collapses = $template->isMergeable()
                || $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS;

            if (!$collapses) {
                $rest[] = $template;
                continue;
            }

            $key     = $template->getName();
            $current = $best[$key] ?? null;

            if ($current === null || $template->getDurationMinutes() > $current->getDurationMinutes()) {
                $best[$key] = $template;
            }
        }

        $ordered = array_merge(array_values($best), $rest);

        usort(
            $ordered,
            static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(),
        );

        return $ordered;
    }

    /**
     * تعداد منبعِ هر نقش پس از ادغام: **بیشینه**، نه جمع و نه اولی.
     *
     * دو ناحیه‌ای که هرکدام یک اتاق می‌خواهند، با هم دو اتاق نمی‌خواهند — همان یک اتاق
     * است. ولی اگر یکی دو اپراتور لازم داشت، ادغام نباید آن را به یک تنزل بدهد؛
     * کم گرفتنش یعنی نوبتی که سرِ وقتش نیروی کافی ندارد.
     *
     * @param SegmentTemplate[] $templates
     * @return array<string, array<string, int>> `[نامِ بخش => [کدِ نقش => تعداد]]`
     */
    private function mergedCounts(array $templates): array
    {
        $counts = [];

        foreach ($templates as $template) {
            foreach ($template->getRequirements() as $requirement) {
                $name = $template->getName();
                $role = $requirement->getResourceType()->getCode();

                $counts[$name][$role] = max($counts[$name][$role] ?? 0, $requirement->getCount());
            }
        }

        return $counts;
    }

    /**
     * قوانین «منبع»: نقشی که قانون لازم می‌داند، اگر الگو نداشته باشد، اضافه می‌شود.
     *
     * نقشِ اضافه‌شده به **اولین بخشی که بیمار حاضر است** می‌چسبد، نه به همهٔ بخش‌ها:
     * «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ
     * آماده‌سازی هم اشغال شود.
     *
     * ممنوعیت هم اینجا خوانده می‌شود: قانونی که می‌گوید این ترکیب در این شعبه انجام
     * نمی‌شود، پیش از رسیدن به موتور دسترس‌پذیری جلوی کار را می‌گیرد.
     *
     * @param ServiceItem[]        $selectedItems
     * @param list<PlannedSegment> $segments
     * @return list<PlannedSegment>
     */
    private function applyResourcePolicies(
        ServiceItem $service,
        array $selectedItems,
        DoctorAddress $address,
        array $segments,
    ): array {
        if ($segments === []) {
            return $segments;
        }

        $outcome = $this->resourcePolicies->evaluate(
            $address->tenantEntityType(),
            $address->tenantEntityId(),
            [
                'service_uuid'     => $service->getUuid(),
                'catalog_category' => $service->getCatalogCategory()?->getUuid(),
                'item_count'       => count($selectedItems),
            ],
            $address,
            $service,
        );

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

        $required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []);

        if ($required === []) {
            return $segments;
        }

        $present = [];
        foreach ($segments as $segment) {
            foreach ($segment->requirements as $requirement) {
                $present[$requirement->role] = true;
            }
        }

        $targetIndex = $this->firstPatientPresentIndex($segments);
        $extra       = [];

        foreach ($required as $code) {
            if (!is_string($code) || isset($present[$code])) {
                continue;
            }

            $extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies);
        }

        if ($extra === []) {
            return $segments;
        }

        $target = $segments[$targetIndex];

        $segments[$targetIndex] = new PlannedSegment(
            sequence: $target->sequence,
            name: $target->name,
            offsetMinutes: $target->offsetMinutes,
            durationMinutes: $target->durationMinutes,
            patientPresent: $target->patientPresent,
            mergeable: $target->mergeable,
            requirements: [...$target->requirements, ...$extra],
        );

        return array_values($segments);
    }

    /** @param list<PlannedSegment> $segments */
    private function firstPatientPresentIndex(array $segments): int
    {
        foreach ($segments as $index => $segment) {
            if ($segment->patientPresent) {
                return $index;
            }
        }

        return 0;
    }

    /**
     * قانونی که نقشِ ناشناخته یا بی‌منبع می‌خواهد **خطاست، نه بی‌اثر**: در سکوت رد
     * کردنش یعنی کلینیک فکر کند قانونش اجرا می‌شود در حالی که هیچ‌وقت نشده.
     */
    /**
     * @param list<array<string, mixed>> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون
     */
    private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement
    {
        // بدون نام قانون، اپراتور می‌داند چه چیزی کم است ولی نه چرا لازم شده — و بین ده
        // قانون فعال باید حدس بزند کدام را خاموش کند.
        $names  = array_values(array_filter(array_map(
            static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null,
            $appliedPolicies,
        )));
        $because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names));

        $type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);

        if ($type === null) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because),
                422,
                'requirements',
            );
        }

        $eligible = array_values($this->resources->findEligible($address, $type, []));

        if ($eligible === []) {
            throw new AppException(
                ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
                sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because),
                422,
                'requirements',
            );
        }

        return new PlannedRequirement(
            role: $type->getCode(),
            roleName: $type->getName(),
            count: 1,
            occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
            constraints: [],
            eligible: $eligible,
            skillName: null,
            setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
            cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
        );
    }

    /** @return list<PlannedRequirement> */
    private function planRequirements(
        SegmentTemplate $template,
        DoctorAddress $address,
        ?string $patientGender,
        array $mergedCounts = [],
    ): array {
        $planned = [];

        foreach ($template->getRequirements() as $requirement) {
            $eligible = $this->eligibleFor($requirement, $address, $patientGender, $template);
            $count    = max(
                $requirement->getCount(),
                $mergedCounts[$requirement->getResourceType()->getCode()] ?? 0,
            );

            if (count($eligible) < $count) {
                throw new AppException(
                    ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
                    $this->explainMissing($requirement, $address, $patientGender),
                    422,
                    'requirements',
                );
            }

            // setup/cleanup محافظه‌کارانه از بیشترین مقدارِ کاندیدها گرفته می‌شود:
            // تسک ۰۶ هنوز نمی‌داند کدام منبع انتخاب می‌شود، و کم گرفتنش یعنی نوبت
            // بعدی روی زمان تمیزکاری بیفتد.
            $planned[] = new PlannedRequirement(
                role: $requirement->getResourceType()->getCode(),
                roleName: $requirement->getResourceType()->getName(),
                count: $count,
                occupancy: $requirement->getOccupancy(),
                constraints: $requirement->getConstraints(),
                eligible: $eligible,
                skillName: $requirement->getSkill()?->getName(),
                setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
                cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
            );
        }

        return $planned;
    }

    /** @return list<ClinicResource> */
    private function eligibleFor(
        SegmentRequirement $requirement,
        DoctorAddress $address,
        ?string $patientGender,
        SegmentTemplate $template,
    ): array {
        $skillIds = $requirement->getSkill() === null ? [] : [(int) $requirement->getSkill()->getId()];
        $eligible = $this->resources->findEligible($address, $requirement->getResourceType(), $skillIds);

        if (!$requirement->requiresSameGender()) {
            return array_values($eligible);
        }

        // قید جنسیت وقتی جنسیت بیمار نامشخص است **نادیده گرفته نمی‌شود**: رد کردن
        // بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود.
        if ($patientGender === null || $patientGender === '') {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_002,
                sprintf('برای بخش «%s» ثبت جنسیت بیمار الزامی است', $template->getName()),
                422,
                'patient_gender',
            );
        }

        return array_values(array_filter(
            $eligible,
            static fn (ClinicResource $r): bool => ($r->getAttributes()['gender'] ?? null) === $patientGender,
        ));
    }

    private function explainMissing(
        SegmentRequirement $requirement,
        DoctorAddress $address,
        ?string $patientGender,
    ): string {
        $parts = [sprintf('هیچ %s', $requirement->getResourceType()->getName())];

        if ($requirement->requiresSameGender() && $patientGender !== null) {
            $parts[] = $patientGender === 'female' ? 'خانمی' : 'آقایی';
        }

        if ($requirement->getSkill() !== null) {
            $parts[] = sprintf('با مهارت «%s»', $requirement->getSkill()->getName());
        }

        $parts[] = sprintf('در شعبهٔ «%s» موجود نیست', $address->getName() ?? '—');

        return implode(' ', $parts);
    }

    /**
     * رفتار امروز، بیان‌شده به زبان برنامه: یک بخش پیوسته که پزشک را می‌گیرد.
     */
    /** @return list<PlannedSegment> */
    private function singleSegment(
        ServiceItem $service,
        DoctorAddress $address,
        int $minutes,
    ): array {
        if ($minutes <= 0) {
            throw new AppException(
                ErrorCodes::ERR_VALIDATION_001,
                sprintf('مدت سرویس «%s» تعریف نشده است', $service->getName()),
                422,
                'duration_minutes',
            );
        }

        $doctorType = $this->types->findByCode(
            $address->tenantEntityType(),
            $address->tenantEntityId(),
            ResourceType::CODE_DOCTOR,
        );

        $eligible = $doctorType === null
            ? []
            : $this->resources->findEligible($address, $doctorType);

        $requirements = $doctorType === null ? [] : [new PlannedRequirement(
            role: ResourceType::CODE_DOCTOR,
            roleName: $doctorType->getName(),
            count: 1,
            occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
            constraints: [],
            eligible: $eligible,
            setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
            cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
        )];

        return [new PlannedSegment(
            sequence: 1,
            name: $service->getName(),
            offsetMinutes: 0,
            durationMinutes: $minutes,
            patientPresent: true,
            mergeable: false,
            requirements: $requirements,
        )];
    }

    /** @param ClinicResource[] $resources */
    private function maxOf(array $resources, callable $pick): int
    {
        $values = array_map($pick, $resources);

        return $values === [] ? 0 : max($values);
    }
}
