<?php

namespace App\Appointment\Service;

use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\DateOverrideRepository;
use App\Appointment\Repository\HolidayRepository;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;


class SlotCalculatorService
{
    /** دلایل خالی‌بودن یک روز — برای پیام دقیق در پنل. */
    public const EMPTY_NO_SCHEDULE    = 'no_schedule';
    public const EMPTY_HOLIDAY        = 'holiday';
    public const EMPTY_DAY_OFF        = 'day_off';
    public const EMPTY_OUTSIDE_WINDOW = 'outside_window';

    public function __construct(
        private readonly WeeklyScheduleRepository $scheduleRepo,
        private readonly DateOverrideRepository   $overrideRepo,
        private readonly HolidayRepository        $holidayRepo,
        private readonly AppointmentRepository    $appointmentRepo,
    ) {}

    /**
     * Returns available slots (flat array) for booking conflict checks.
     * Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه)
     *
     * @return array[] [{start, end, start_time, end_time, location_id}]
     */
    public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
    {
        $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
        if (empty($sessions)) return [];
        $flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
        return $this->filterBookedSlots($doctor, $flat);
    }

    /**
     * آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null.
     *
     * برای ثبتِ نوبت از پنل ($forManagement=true) نباید خاموش‌بودنِ نوبت‌دهی آنلاین
     * باعث گم‌شدنِ location شود؛ وگرنه نوبتِ دستی بدون آدرس ثبت می‌شد.
     */
    public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null, bool $forManagement = false): ?int
    {
        $date     = date('Y-m-d', $slotStart);
        $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
        foreach ($sessions as $session) {
            foreach (($session['slots'] ?? []) as $slot) {
                if ((int) ($slot['start'] ?? 0) === $slotStart) {
                    $loc = $slot['location_id'] ?? null;
                    return $loc !== null ? (int) $loc : null;
                }
            }
        }
        return null;
    }

    /**
     * Returns sessions grouped by shift, each slot tagged with is_available.
     * Used by the schedule view to show real shift boundaries.
     *
     * @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}]
     */
    public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
    {
        $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
        $now = time();
        return array_map(fn(array $session) => [
            'start_time' => $session['start_time'],
            'end_time'   => $session['end_time'],
            'slots'      => array_map(fn(array $slot) => array_merge($slot, [
                'is_available' => $slot['start'] >= $now
                    && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']),
            ]), $session['slots']),
        ], $sessions);
    }

    /**
     * Whether a doctor has at least one slot on the given date.
     * Lightweight check for the month-availability endpoint.
     */
    public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool
    {
        return !empty($this->buildAllSessions($doctor, $date, $clinic, $forManagement));
    }

    /**
     * حالت نوبت‌دهی سرویسی: زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes
     * در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت
     * سرویس (+ بافر) پُر می‌کند: از ابتدای window شروع، بازه‌های اشغال‌شده را رد
     * می‌کند و اولین جای پیوستهٔ کافی را برمی‌گرداند، سپس نوبت‌های بعدی را پشت‌سرهم
     * (با فاصلهٔ بافر) می‌چیند.
     *
     * زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
     * بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود.
     *
     * `$excludeAppointmentId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال
     * جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش در فهرست نمی‌آید. همان الگوی
     * {@see \App\Appointment\Repository\AppointmentRepository::isSlotTaken()}.
     *
     * @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
     */
    public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false, ?int $excludeAppointmentId = null): array
    {
        if ($durationMinutes <= 0) return [];

        $buffer  = (int)($this->getBookingMeta($doctor, $clinic)['buffer_minutes'] ?? 0);
        $durSec  = $durationMinutes * 60;
        $needSec = $durSec + $buffer * 60;   // فضای لازم شامل بافر

        $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);   // window/holiday/override/booking-window رعایت می‌شود
        if (empty($sessions)) return [];

        $dayStart = (int) strtotime($date . ' 00:00:00');
        $busy     = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400, $excludeAppointmentId);
        $now      = time();

        $result = [];
        foreach ($sessions as $session) {
            $winStart   = $dayStart + $this->parseTime($session['start_time'] ?? '00:00');
            $winEnd     = $dayStart + $this->parseTime($session['end_time']   ?? '00:00');
            $locationId = $session['slots'][0]['location_id'] ?? null;

            $t = max($winStart, $now);
            while ($t + $durSec <= $winEnd) {
                $end = $t + $durSec;
                $conflict = $this->firstOverlap($t, $t + $needSec, $busy);
                if ($conflict !== null) {
                    $t = $conflict;   // به انتهای بازهٔ اشغال‌شدهٔ متداخل بپر
                    continue;
                }
                $result[] = [
                    'start'       => $t,
                    'end'         => $end,
                    'start_time'  => date('H:i', $t),
                    'end_time'    => date('H:i', $end),
                    'location_id' => $locationId !== null ? (int) $locationId : null,
                ];
                $t += $needSec;   // نوبت بعدی پس از این نوبت + بافر
            }
        }
        return $result;
    }

    /**
     * چرا این روز اسلاتی ندارد. null یعنی اسلات دارد.
     *
     * پنل نمی‌تواند خالی‌بودن را به «تعطیل» ترجمه کند: نبودِ برنامه، تعطیلی، روزِ
     * بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید
     * بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند.
     */
    public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): ?string
    {
        if ($this->buildAllSessions($doctor, $date, $clinic, $forManagement) !== []) {
            return null;
        }

        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        if ($schedule === null) {
            return self::EMPTY_NO_SCHEDULE;
        }

        $dayStart = (int) strtotime($date . ' 00:00:00');

        if ($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayStart + 86399, $clinic) !== []) {
            return self::EMPTY_HOLIDAY;
        }

        if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
            return self::EMPTY_OUTSIDE_WINDOW;
        }

        return self::EMPTY_DAY_OFF;
    }

    /**
     * زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد.
     *
     * برخلاف صدا زدن getAvailableSlots() به ازای هر روز، برنامه و تعطیلی و استثناها
     * و نوبت‌های اشغال یک‌بار برای کل بازه واکشی می‌شوند و بقیه در حافظه محاسبه
     * می‌شود: ۴ کوئری ثابت به‌جای رشدِ خطی با تعداد روز و اسلات.
     */
    public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30, bool $forManagement = false): ?int
    {
        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        if ($schedule === null) {
            return null;
        }

        $meta = $schedule->getMeta();
        if (!$forManagement && !($meta['online_booking_enabled'] ?? true)) {
            return null;
        }

        $now       = time();
        $todayStart = (int) strtotime('today 00:00:00');
        $windowEnd  = $this->bookingWindowEnd($meta);
        $scanEnd    = min($windowEnd, $todayStart + $daysAhead * 86400);
        if ($scanEnd < $todayStart) {
            return null;
        }

        $holidays  = $this->holidayRepo->findActiveByDoctor($doctor, $todayStart, $scanEnd + 86399, $clinic);
        $blocking  = $this->appointmentRepo->findBlockingIntervals($doctor, $now, $scanEnd + 86400);
        $overrides = [];
        foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) {
            $overrides[date('Y-m-d', $override->getDate())] = $override;
        }

        $daySchedule = $schedule->getSetting();

        for ($dayStart = $todayStart; $dayStart <= $scanEnd; $dayStart += 86400) {
            if ($this->isHoliday($holidays, $dayStart)) {
                continue;
            }

            $date     = date('Y-m-d', $dayStart);
            $override = $overrides[$date] ?? null;

            if ($override !== null) {
                if (!$override->isActive()) {
                    continue;
                }
                $sessions = $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
            } else {
                $dayKey  = (string) (((int) date('w', $dayStart) + 1) % 7);
                $dayConf = $daySchedule[$dayKey] ?? null;
                if ($dayConf === null) {
                    continue;
                }
                $sessions = [];
                foreach (($dayConf['sessions'] ?? []) as $session) {
                    if ($session['active'] ?? false) {
                        $sessions[] = ['slots' => $this->buildSessionSlots($session, $dayStart)];
                    }
                }
            }

            foreach ($sessions as $session) {
                foreach (($session['slots'] ?? []) as $slot) {
                    if ($slot['start'] >= $now && $this->firstOverlap($slot['start'], $slot['end'], $blocking) === null) {
                        return (int) $slot['start'];
                    }
                }
            }
        }

        return null;
    }

    /** @param \App\Appointment\Entity\Holiday[] $holidays */
    private function isHoliday(array $holidays, int $dayStart): bool
    {
        $dayEnd = $dayStart + 86399;
        foreach ($holidays as $holiday) {
            if ($holiday->getStartDate() <= $dayEnd && $holiday->getEndDate() >= $dayStart) {
                return true;
            }
        }

        return false;
    }

    /** آخرین روزی که رزرو عمومی روی آن مجاز است (شروعِ آن روز، 00:00). */
    private function bookingWindowEnd(array $meta): int
    {
        $value = max(1, (int) ($meta['booking_window_value'] ?? WeeklySchedule::DEFAULT_META['booking_window_value']));
        $unit  = in_array($meta['booking_window_unit'] ?? null, WeeklySchedule::BOOKING_WINDOW_UNITS, true)
            ? $meta['booking_window_unit']
            : WeeklySchedule::DEFAULT_META['booking_window_unit'];

        return (int) strtotime("today +{$value} {$unit} 00:00:00");
    }

    /**
     * انتهای اولین بازهٔ اشغال‌شده‌ای که با [$start, $end) تداخل دارد، یا null.
     * @param array<array{start:int,end:int}> $busy
     */
    private function firstOverlap(int $start, int $end, array $busy): ?int
    {
        foreach ($busy as $b) {
            if ($b['start'] < $end && $b['end'] > $start) {
                return $b['end'];
            }
        }
        return null;
    }

    /**
     * Booking is allowed only when online booking is enabled and the date is
     * today..(today + window). Past dates are always rejected.
     *
     * مدیریت پنل ($forManagement=true): خاموش‌بودنِ نوبت‌دهی آنلاین و سقفِ بازهٔ
     * مجاز رزرو (advance window) فقط قواعد رزرو عمومی از سایت‌اند و نباید جلوی
     * نمایش/ثبتِ نوبت توسط پزشک/منشی/ادمین را بگیرند. تاریخِ گذشته همچنان رد می‌شود.
     */
    private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool
    {
        $todayStart = (int) strtotime('today 00:00:00');
        if ($dayStart < $todayStart) {
            return false;
        }

        if ($forManagement) {
            return true;
        }

        $meta = $this->getBookingMeta($doctor, $clinic);
        if (!($meta['online_booking_enabled'] ?? true)) {
            return false;
        }

        return $dayStart <= $this->bookingWindowEnd($meta);
    }

    private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array
    {
        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
    }

    /**
     * Core: build all sessions with their slots, grouped by shift.
     *
     * @return array[] [{start_time: string, end_time: string, slots: array[]}]
     */
    private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
    {
        $dayStart = (int) strtotime($date . ' 00:00:00');
        $dayEnd   = $dayStart + 86400;

        // 0. Online booking disabled or date outside the booking window
        //    (در کانتکست مدیریت این دو نادیده گرفته می‌شوند — رجوع به isWithinBookingWindow)
        if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
            return [];
        }

        // 1. Blocked by holiday
        if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1, $clinic))) {
            return [];
        }

        // 2. Date override takes precedence over weekly schedule
        foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) {
            if (date('Y-m-d', $override->getDate()) === $date) {
                if (!$override->isActive()) return [];
                return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
            }
        }

        // 3. Weekly schedule
        $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
        if ($schedule === null) return [];

        // Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday)
        $phpDow = (int) date('w', $dayStart);
        $dayKey = (string)(($phpDow + 1) % 7);

        $dayConf = $schedule->getSetting()[$dayKey] ?? null;
        if ($dayConf === null) return [];

        $sessionConfigs = $dayConf['sessions'] ?? [];
        $activeSessions = array_filter($sessionConfigs, fn($s) => $s['active'] ?? false);
        usort($activeSessions, fn($a, $b) =>
            $this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00')
        );

        $result  = [];
        $prevEnd = 0;
        foreach ($activeSessions as $session) {
            $sessionStart = $this->parseTime($session['start_time'] ?? '00:00');
            if ($sessionStart < $prevEnd) continue; // skip overlapping session
            $slots = $this->buildSessionSlots($session, $dayStart);
            if (!empty($slots)) {
                $result[] = [
                    'start_time' => $session['start_time'] ?? '00:00',
                    'end_time'   => $session['end_time']   ?? '00:00',
                    'slots'      => $slots,
                ];
            }
            $prevEnd = $this->parseTime($session['end_time'] ?? '00:00');
        }

        return $result;
    }

    /**
     * Build sessions from date-override config.
     * Supports new SessionConfig format {start_time, end_time, ...} and legacy {start, end, duration}.
     */
    private function buildSessionsFromOverride(array $slotConfigs, int $dayStart): array
    {
        $sessions = [];
        foreach ($slotConfigs as $config) {
            if (isset($config['start_time'])) {
                $slots = $this->buildSessionSlots(array_merge(['active' => true], $config), $dayStart);
                if (!empty($slots)) {
                    $sessions[] = [
                        'start_time' => $config['start_time'],
                        'end_time'   => $config['end_time'] ?? '00:00',
                        'slots'      => $slots,
                    ];
                }
            } else {
                // Legacy flat format: {start, end, duration}
                $startSec = $this->parseTime($config['start'] ?? '00:00');
                $endSec   = $this->parseTime($config['end']   ?? '00:00');
                $duration = (int)($config['duration'] ?? 30) * 60;
                if ($duration <= 0 || $endSec <= $startSec) continue;
                $slots = [];
                for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
                    $slots[] = [
                        'start'       => $dayStart + $t,
                        'end'         => $dayStart + $t + $duration,
                        'start_time'  => gmdate('H:i', $t),
                        'end_time'    => gmdate('H:i', $t + $duration),
                        'location_id' => null,
                    ];
                }
                if (!empty($slots)) {
                    $sessions[] = [
                        'start_time' => $config['start'] ?? '00:00',
                        'end_time'   => $config['end']   ?? '00:00',
                        'slots'      => $slots,
                    ];
                }
            }
        }
        return $sessions;
    }

    /**
     * Build individual slot entries from a session config (weekly or override).
     * Handles rest breaks and patient limits.
     */
    private function buildSessionSlots(array $session, int $dayStart): array
    {
        $startSec   = $this->parseTime($session['start_time'] ?? '00:00');
        $endSec     = $this->parseTime($session['end_time']   ?? '00:00');
        $dur        = (int)($session['duration_per_patient'] ?? 20) * 60;
        $hasRest    = (bool)($session['has_rest'] ?? false);
        $restInt    = (int)($session['rest_interval'] ?? 60) * 60;
        $restDur    = (int)($session['time_to_rest']  ?? 10) * 60;
        $limit      = isset($session['patient_limit']) && $session['patient_limit'] !== null
                        ? (int)$session['patient_limit'] : null;
        $locationId = isset($session['location_id']) ? (int)$session['location_id'] : null;

        if ($dur <= 0 || $endSec <= $startSec) return [];

        $slots        = [];
        $currentSec   = $startSec;
        $elapsedWork  = 0;
        $patientCount = 0;

        while ($currentSec + $dur <= $endSec) {
            if ($limit !== null && $patientCount >= $limit) break;

            if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) {
                $currentSec  += $restDur;
                $elapsedWork  = 0;
                continue;
            }

            $slots[] = [
                'start'       => $dayStart + $currentSec,
                'end'         => $dayStart + $currentSec + $dur,
                'start_time'  => gmdate('H:i', $currentSec),
                'end_time'    => gmdate('H:i', $currentSec + $dur),
                'location_id' => $locationId,
            ];

            $currentSec   += $dur;
            $elapsedWork  += $dur;
            $patientCount += 1;
        }

        return $slots;
    }

    private function filterBookedSlots(Doctor $doctor, array $slots): array
    {
        $now = time();
        return array_values(array_filter($slots, fn(array $slot): bool =>
            $slot['start'] >= $now
            && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end'])
        ));
    }

    private function parseTime(string $time): int
    {
        [$h, $m] = explode(':', $time, 2) + [0, 0];
        return ((int)$h * 3600) + ((int)$m * 60);
    }
}
