<?php

namespace App\Resource\Service;

use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceException;
use App\Resource\Repository\NationalHolidayRepository;
use App\Resource\Repository\ResourceCalendarRepository;
use App\Resource\Repository\ResourceExceptionRepository;
use App\Resource\Repository\TenantHolidayOverrideRepository;
use App\Resource\ValueObject\DayAvailability;
use App\Shared\Time\TimeInterval;

/**
 * «ساعت آزادِ خامِ یک منبع در یک بازه» — لایه‌های ۱ تا ۴ از کسرِ بند ۹ مستند:
 *
 *   ساعت کاری شعبه ∩ شیفت منبع − تعطیلات رسمی − استثناهای منبع
 *
 * **نوبت‌های ثبت‌شده و رزروهای موقت اینجا کسر نمی‌شوند** — آن‌ها تسک ۰۶/۰۷ هستند و
 * تقاطع چند منبع هم همان‌جاست. اسم متد `rawAvailability` عمدی است تا کسی این خروجی
 * را «وقت قابل رزرو» نپندارد.
 *
 * این سرویس هیچ ارتباطی با `SlotCalculatorService` ندارد و آن را صدا نمی‌زند: مسیر
 * اسلاتیِ موجود در این فاز قفل است ({@see docs/new_feture/taskes/_shared/red-lines.md}).
 */
final class ResourceAvailabilityService
{
    /** حداکثر بازهٔ قابل پرس‌وجو — بی‌حد گذاشتنش یعنی یک درخواست می‌تواند سال‌ها را بسازد. */
    public const MAX_DAYS = 92;

    public const DAY_SECONDS = 86400;

    public function __construct(
        private readonly ResourceCalendarRepository      $calendars,
        private readonly ResourceExceptionRepository     $exceptions,
        private readonly BranchWorkingHoursRepository    $branchHours,
        private readonly NationalHolidayRepository       $holidays,
        private readonly TenantHolidayOverrideRepository $overrides,
    ) {}

    /**
     * @param int $from نیمه‌شبِ روز آغاز (به وقت محلی شعبه)
     * @param int $to   نیمه‌شبِ روز پایان — خودِ این روز هم شامل است
     * @return DayAvailability[]
     */
    public function rawAvailability(ClinicResource $resource, int $from, int $to): array
    {
        $timezone  = new \DateTimeZone($resource->getAddress()->getTimezone());
        $startDay  = $this->midnight($from, $timezone);
        $endDay    = $this->midnight($to, $timezone);

        // شیفت‌ها و ساعت شعبه یک بار خوانده می‌شوند، نه per روز.
        $shiftsByDay = $this->shiftsByDay($resource);
        $branchByDay = $this->branchHoursByDay($resource);

        $holidayMap  = $this->holidays->mapForRange($startDay, $endDay);
        $overrideMap = $this->overrides->mapForRange(
            $resource->getEntityType(),
            $resource->getEntityId(),
            $startDay,
            $endDay,
        );

        $exceptions = $this->exceptions->findOverlapping(
            $resource,
            $startDay,
            $endDay + self::DAY_SECONDS,
        );

        $days = [];

        for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) {
            $days[] = $this->buildDay(
                $resource,
                $day,
                $timezone,
                $shiftsByDay,
                $branchByDay,
                $holidayMap,
                $overrideMap,
                $exceptions,
            );
        }

        return $days;
    }

    /**
     * همان `rawAvailability` برای چند منبع، ولی با خواندنِ دسته‌ای.
     *
     * تعطیلات و استثناهای محیط و ساعت شعبه برای همهٔ منابع یکی‌اند و بیرون حلقه خوانده
     * می‌شوند؛ شیفت و استثنای هر منبع هم با یک کوئری برای همه می‌آید. بدون این، گزارشِ
     * چهل منبع دویست کوئری می‌زد.
     *
     * @param ClinicResource[] $resources همهٔ آن‌ها باید یک شعبه داشته باشند
     * @return array<int, list<DayAvailability>> کلید: شناسهٔ منبع
     */
    public function rawAvailabilityForAll(array $resources, int $from, int $to): array
    {
        if ($resources === []) {
            return [];
        }

        $first     = $resources[array_key_first($resources)];
        $timezone  = new \DateTimeZone($first->getAddress()->getTimezone());
        $startDay  = $this->midnight($from, $timezone);
        $endDay    = $this->midnight($to, $timezone);

        $holidayMap  = $this->holidays->mapForRange($startDay, $endDay);
        $overrideMap = $this->overrides->mapForRange(
            $first->getEntityType(),
            $first->getEntityId(),
            $startDay,
            $endDay,
        );

        $branchByDay = $this->branchHoursByDay($first);

        $ids           = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
        $shiftsById    = $this->calendars->findForResources($ids);
        $exceptionsById = $this->exceptions->findOverlappingForResources($ids, $startDay, $endDay + self::DAY_SECONDS);

        $out = [];

        foreach ($resources as $resource) {
            $id    = (int) $resource->getId();
            $byDay = [];

            foreach ($shiftsById[$id] ?? [] as $shift) {
                if ($shift->isActive()) {
                    $byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute());
                }
            }

            $shiftsByDay = array_map(TimeInterval::mergeAll(...), $byDay);
            $days        = [];

            for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) {
                $days[] = $this->buildDay(
                    $resource,
                    $day,
                    $timezone,
                    $shiftsByDay,
                    $branchByDay,
                    $holidayMap,
                    $overrideMap,
                    $exceptionsById[$id] ?? [],
                );
            }

            $out[$id] = $days;
        }

        return $out;
    }

    /**
     * @param array<int, list<TimeInterval>>  $shiftsByDay
     * @param array<int, list<TimeInterval>>|null $branchByDay
     * @param array<int, \App\Resource\Entity\NationalHoliday> $holidayMap
     * @param array<int, \App\Resource\Entity\TenantHolidayOverride> $overrideMap
     * @param ResourceException[] $exceptions
     */
    private function buildDay(
        ClinicResource $resource,
        int $midnight,
        \DateTimeZone $timezone,
        array $shiftsByDay,
        ?array $branchByDay,
        array $holidayMap,
        array $overrideMap,
        array $exceptions,
    ): DayAvailability {
        $reasons   = [];
        $dayOfWeek = $this->dayOfWeek($midnight, $timezone);

        if (!$resource->isActive()) {
            return new DayAvailability($midnight, $dayOfWeek, [], ['resource_inactive']);
        }

        if (!$resource->getAddress()->isActive()) {
            return new DayAvailability($midnight, $dayOfWeek, [], ['branch_inactive']);
        }

        $override = $overrideMap[$midnight] ?? null;
        $holiday  = $holidayMap[$midnight] ?? null;

        // استثنای محیط بر تقویم رسمی مقدم است — در هر دو جهت.
        $closedByHoliday = $override !== null ? !$override->isWorking() : $holiday !== null;

        if ($closedByHoliday) {
            $reasons[] = $override !== null ? 'tenant_holiday' : 'national_holiday';

            return new DayAvailability($midnight, $dayOfWeek, [], $reasons);
        }

        $shifts = $shiftsByDay[$dayOfWeek] ?? [];

        if ($shifts === []) {
            return new DayAvailability($midnight, $dayOfWeek, [], ['no_shift']);
        }

        // شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «بسته»: شیفت منبع بی‌قید اعمال
        // می‌شود تا دادهٔ موجود دقیقاً مثل امروز کار کند (قرارداد تسک ۰۱).
        if ($branchByDay !== null) {
            $branchWindows = $branchByDay[$dayOfWeek] ?? [];

            if ($branchWindows === []) {
                return new DayAvailability($midnight, $dayOfWeek, [], ['branch_closed']);
            }

            $intersected = TimeInterval::intersectAll($shifts, $branchWindows);

            // شیفت هست ولی تقاطعش با ساعت شعبه خالی شد — این با «شیفتی نیست» فرق دارد
            // و بدون دلیل صریح، پاسخِ خالی از یک باگ قابل تشخیص نیست.
            if ($intersected === []) {
                $reasons[] = 'outside_branch_hours';
            }

            $shifts = $intersected;
        }

        $absolute = array_map(
            static fn (TimeInterval $i): TimeInterval => $i->minutesToAbsolute($midnight),
            $shifts,
        );

        $blocking = [];
        foreach ($exceptions as $exception) {
            if ($exception->getStartsAt() < $this->endOfDay($midnight, $timezone)
                && $exception->getEndsAt() > $midnight
            ) {
                $blocking[] = new TimeInterval($exception->getStartsAt(), $exception->getEndsAt());
            }
        }

        if ($blocking !== []) {
            $before   = $absolute;
            $absolute = TimeInterval::subtractAll($absolute, $blocking);

            if ($absolute !== $before) {
                $reasons[] = 'exception';
            }
        }

        return new DayAvailability($midnight, $dayOfWeek, $absolute, $reasons);
    }

    /** @return array<int, list<TimeInterval>> روز هفته => بازه‌های دقیقه‌ای */
    private function shiftsByDay(ClinicResource $resource): array
    {
        $byDay = [];

        foreach ($this->calendars->findForResource($resource) as $shift) {
            if (!$shift->isActive()) {
                continue;
            }

            $byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute());
        }

        return array_map(TimeInterval::mergeAll(...), $byDay);
    }

    /**
     * `null` یعنی این شعبه اصلاً ساعت کاری تعریف‌شده ندارد — که با «همهٔ روزها بسته»
     * فرق دارد و نباید با آن یکی گرفته شود.
     *
     * @return array<int, list<TimeInterval>>|null
     */
    private function branchHoursByDay(ClinicResource $resource): ?array
    {
        $rows = $this->branchHours->findForAddress($resource->getAddress());

        if ($rows === []) {
            return null;
        }

        $byDay = [];
        foreach ($rows as $row) {
            if (!$row->isActive()) {
                continue;
            }

            $byDay[$row->getDayOfWeek()][] = new TimeInterval($row->getStartMinute(), $row->getEndMinute());
        }

        return array_map(TimeInterval::mergeAll(...), $byDay);
    }

    /** ۰=شنبه … ۶=جمعه — همان قرارداد بقیهٔ سامانه، نه `w` استاندارد PHP. */
    public function dayOfWeek(int $timestamp, \DateTimeZone $timezone): int
    {
        $date = (new \DateTimeImmutable('@' . $timestamp))->setTimezone($timezone);

        return ((int) $date->format('w') + 1) % 7;
    }

    public function midnight(int $timestamp, \DateTimeZone $timezone): int
    {
        return (new \DateTimeImmutable('@' . $timestamp))
            ->setTimezone($timezone)
            ->setTime(0, 0)
            ->getTimestamp();
    }

    /**
     * روز بعد از روی تقویم گرفته می‌شود نه با `+86400`: در منطقه‌هایی که ساعت تابستانی
     * دارند، روز ۲۳ یا ۲۵ ساعته می‌شود و جمعِ ثابت، نیمه‌شب را جابه‌جا می‌کند.
     */
    private function nextMidnight(int $midnight, \DateTimeZone $timezone): int
    {
        return (new \DateTimeImmutable('@' . $midnight))
            ->setTimezone($timezone)
            ->modify('+1 day')
            ->setTime(0, 0)
            ->getTimestamp();
    }

    private function endOfDay(int $midnight, \DateTimeZone $timezone): int
    {
        return $this->nextMidnight($midnight, $timezone);
    }
}
