<?php

namespace App\Appointment\Availability\Service;

use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
use App\Appointment\Availability\ValueObject\AvailableSlot;
use App\Appointment\Availability\ValueObject\SlotAssignment;
use App\Appointment\Availability\Picker\PickContext;
use App\Appointment\Availability\Picker\ResourcePickerRegistry;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Appointment\Plan\ValueObject\PlannedRequirement;
use App\Appointment\Plan\ValueObject\PlannedSegment;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Service\ResourceAvailabilityService;
use App\Shared\Time\TimeInterval;

/**
 * برنامهٔ نوبت را روی تقویم منابع می‌لغزاند و می‌گوید چه ساعت‌هایی **واقعاً** ممکن‌اند،
 * با پیشنهاد اینکه کدام منبع استفاده شود (بند ۱۰ مستند).
 *
 * ## چرا این ظرفیت آزاد می‌کند
 *
 * تخصیص **per نقش** انجام می‌شود، نه per بخش: اپراتوری که در بخش «انتظار اثر کرم»
 * نیازمندی ندارد، در آن دقایق اصلاً بررسی نمی‌شود و برای بیمار دیگری آزاد است.
 * همین تفاوت، نیمِ هدررفتهٔ ظرفیت در مدل تک‌بازه‌ای را برمی‌گرداند.
 *
 * ## چرا همان منبع در بخش‌های غیرمجاور
 *
 * یک منبع برای **همهٔ** بخش‌هایی که آن نقش را می‌خواهند انتخاب می‌شود، نه جداگانه per
 * بخش. اپراتور بخش ۱ و بخش ۳ باید یک نفر باشد؛ انتخاب مستقل، دو نفر می‌داد.
 *
 * ## کارایی
 *
 * همهٔ ورودی‌ها یک بار خوانده می‌شوند (تقویم منابع، اشغال‌ها) و بقیه در حافظه است.
 * هیچ کوئری داخل حلقهٔ کاندید یا حلقهٔ روز نیست.
 */
final class AvailabilityEngine
{
    /** گام پیش‌فرض تولید کاندید. */
    public const DEFAULT_STEP_MINUTES = 15;

    public const MAX_DAYS = 90;

    /** سقف پاسخ — جستجوی یک ماهه نباید هزاران ردیف برگرداند. */
    public const MAX_SLOTS = 500;

    public function __construct(
        private readonly ResourceAvailabilityService $calendars,
        private readonly ResourceOccupancyRepository $occupancy,
        private readonly ResourcePickerRegistry      $pickers,
    ) {}

    /**
     * @return AvailableSlot[]
     */
    public function search(
        AppointmentPlan $plan,
        DoctorAddress $address,
        int $from,
        int $to,
        int $stepMinutes = self::DEFAULT_STEP_MINUTES,
        ?int $now = null,
        ?string $strategy = null,
        array $preferredResourceIds = [],
    ): array {
        $now  = $now ?? time();
        $step = max(5, $stepMinutes) * 60;

        $roles = $this->rolesOf($plan);

        if ($roles === []) {
            // برنامه‌ای که هیچ منبعی نمی‌خواهد فقط به ساعت کاری شعبه محدود است؛
            // چنین چیزی معتبر است («انتظار در خانه») ولی وقت‌دهی ندارد.
            return [];
        }

        $free = $this->freeWindows($roles, $address, $from, $to);
        $slots = [];

        $picker = $this->pickers->get($strategy);

        foreach ($this->candidateStarts($roles, $free, $from, $to, $step, $now) as $start) {
            $assignment = $this->assign($plan, $roles, $free, $start, $picker, $preferredResourceIds);

            if ($assignment === null) {
                continue;
            }

            $slots[] = new AvailableSlot($start, $start + $plan->totalMinutes * 60, $assignment);

            if (count($slots) >= self::MAX_SLOTS) {
                break;
            }
        }

        return $slots;
    }

    /**
     * نقش‌های موردنیاز و بازه‌های هر نقش درون نوبت.
     *
     * @return array<string, array{requirement: PlannedRequirement, windows: list<array{offset: int, duration: int}>}>
     */
    private function rolesOf(AppointmentPlan $plan): array
    {
        $roles = [];

        foreach ($plan->segments as $segment) {
            foreach ($segment->requirements as $requirement) {
                $key = $requirement->role . '#' . ($requirement->skillName ?? '') . '#' . $requirement->count;

                $roles[$key]['requirement'] = $requirement;
                $roles[$key]['windows'][]   = [
                    'offset'   => $segment->offsetMinutes,
                    'duration' => $segment->durationMinutes,
                ];
            }
        }

        return $roles;
    }

    /**
     * پنجره‌های آزاد هر منبع در کل بازه — تقویم منبع منهای اشغال‌ها.
     *
     * @param array<string, array{requirement: PlannedRequirement, windows: list<array{offset:int,duration:int}>}> $roles
     * @return array<int, list<TimeInterval>> شناسهٔ منبع => بازه‌های آزاد
     */
    private function freeWindows(array $roles, DoctorAddress $address, int $from, int $to): array
    {
        $resources = [];
        foreach ($roles as $role) {
            foreach ($role['requirement']->eligible as $resource) {
                $resources[(int) $resource->getId()] = $resource;
            }
        }

        // یک کوئری برای همهٔ اشغال‌های همهٔ منابع در کل بازه.
        $busy = $this->occupancy->busyByResource(array_keys($resources), $from, $to + 86400);
        $free = [];

        foreach ($resources as $id => $resource) {
            $open = [];

            foreach ($this->calendars->rawAvailability($resource, $from, $to) as $day) {
                foreach ($day->intervals as $interval) {
                    $open[] = $interval;
                }
            }

            $blocks = array_map(
                static fn (array $b): TimeInterval => new TimeInterval($b['start'], $b['end']),
                $busy[$id] ?? [],
            );

            $free[$id] = $blocks === [] ? TimeInterval::mergeAll($open) : TimeInterval::subtractAll($open, $blocks);
        }

        return $free;
    }

    /**
     * نقطه‌های شروع کاندید.
     *
     * فقط از پنجره‌های آزادِ **محدودکننده‌ترین نقش** ساخته می‌شوند، نه از کل بازهٔ
     * تاریخ: هرس زودهنگام، جستجوی یک‌ماهه را از ده‌ها هزار کاندید به چند صد می‌رساند.
     *
     * @param array<string, array{requirement: PlannedRequirement, windows: list<array{offset:int,duration:int}>}> $roles
     * @param array<int, list<TimeInterval>> $free
     * @return list<int>
     */
    private function candidateStarts(array $roles, array $free, int $from, int $to, int $step, int $now): array
    {
        $scarcest = null;
        foreach ($roles as $role) {
            $count = count($role['requirement']->eligible);

            if ($scarcest === null || $count < count($scarcest['requirement']->eligible)) {
                $scarcest = $role;
            }
        }

        $earliestOffset = min(array_map(
            static fn (array $w): int => $w['offset'],
            $scarcest['windows'],
        )) * 60;

        $starts = [];
        $limit  = $to + 86400;

        foreach ($scarcest['requirement']->eligible as $resource) {
            foreach ($free[(int) $resource->getId()] ?? [] as $window) {
                // اولین کاندیدِ ممکن، شروعی است که پنجره را از ابتدای همان بازه پوشش دهد.
                $first = $this->ceilToStep($window->start - $earliestOffset, $step, $from);

                for ($start = $first; $start < $limit; $start += $step) {
                    if ($start < $now || $start + $earliestOffset >= $window->end) {
                        continue;
                    }

                    $starts[$start] = true;
                }
            }
        }

        $unique = array_keys($starts);
        sort($unique);

        return $unique;
    }

    private function ceilToStep(int $value, int $step, int $origin): int
    {
        $delta = $value - $origin;

        if ($delta <= 0) {
            return $origin;
        }

        return $origin + (int) (ceil($delta / $step) * $step);
    }

    /**
     * تخصیص منبع برای یک زمان شروع. `null` یعنی این زمان ممکن نیست.
     *
     * @param array<string, array{requirement: PlannedRequirement, windows: list<array{offset:int,duration:int}>}> $roles
     * @param array<int, list<TimeInterval>> $free
     */
    private function assign(
        AppointmentPlan $plan,
        array $roles,
        array $free,
        int $start,
        \App\Appointment\Availability\Picker\ResourcePicker $picker,
        array $preferredResourceIds,
    ): ?SlotAssignment {
        $chosen = [];
        $taken  = [];

        foreach ($roles as $role) {
            $requirement = $role['requirement'];

            // بازه‌هایی که این نقش واقعاً درگیر است — نه کل نوبت.
            $needed = [];
            foreach ($role['windows'] as $window) {
                $segmentStart = $start + $window['offset'] * 60;
                $segmentEnd   = $segmentStart + $window['duration'] * 60;

                // آماده‌سازی و تمیزکاری منبع را هم می‌گیرد: منبع واقعاً در آن دقایق
                // در دسترس نیست.
                $needed[] = new TimeInterval(
                    $segmentStart - $requirement->setupMinutes * 60,
                    $segmentEnd + $requirement->cleanupMinutes * 60,
                );
            }

            $needed = TimeInterval::mergeAll($needed);
            $picked = [];

            // استراتژی فقط **ترتیب** را تعیین می‌کند؛ شرط جا داشتن و برداشته‌نشدن
            // همچنان اینجاست، چون فقط موتور هر دو را می‌داند.
            $ordered = $picker->order(
                $requirement->eligible,
                new PickContext($free, $needed, $start, $preferredResourceIds),
            );

            foreach ($ordered as $resource) {
                $id = (int) $resource->getId();

                if (isset($taken[$id])) {
                    continue;   // یک منبع دو نقش را هم‌زمان پر نمی‌کند
                }

                if (!$this->fits($free[$id] ?? [], $needed)) {
                    continue;
                }

                $picked[] = $resource;
                $taken[$id] = true;

                if (count($picked) === $requirement->count) {
                    break;
                }
            }

            if (count($picked) < $requirement->count) {
                return null;
            }

            $chosen[$requirement->role] = $picked;
        }

        return new SlotAssignment($chosen);
    }

    /**
     * @param list<TimeInterval> $free
     * @param list<TimeInterval> $needed
     */
    private function fits(array $free, array $needed): bool
    {
        foreach ($needed as $interval) {
            $covered = false;

            foreach ($free as $window) {
                if ($window->start <= $interval->start && $window->end >= $interval->end) {
                    $covered = true;
                    break;
                }
            }

            if (!$covered) {
                return false;
            }
        }

        return true;
    }
}
