<?php

namespace routes;

use classes\authentication;
use classes\redis;
use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use classes\workfeed_employee_name_formatter;
use classes\workfeed_shift_time_resolver;
use DateInterval;
use DateTime;
use DateTimeZone;
use Exception;
use Throwable;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;

class moduleWeatherAPIRoute
{
    use route_t;

    private const DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY = 'departments_weather:hot_activity:v1';
    private const DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY = 'departments_weather:refresh_queue:v1';
    private const DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX = 'departments_weather:hot_descriptor:v1:';
    private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:';
    private const DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY = 'weather_status_degraded_threshold';
    private const DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY = 'weather_status_healthy_threshold';
    private const DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD = 1.0;
    private const DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD = 1.3;
    private const DEPARTMENT_WEATHER_STATUS_SEVERITY = [
        'unknown' => 0,
        'healthy' => 1,
        'degraded' => 2,
        'unhealthy' => 3,
    ];

    public function run(): void
    {
        global /** @var response $response */
        /** @var router $router */
        $router, $response;

        $this->get('/modules/weatherapi/current', function () {
            global $response;
            self::requirePermission('modules_weatherapi_current');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            self::requireParameters(['q']);
            self::requireType('q', self::type_string());
            $result = (new weatherapi())->current(self::getParameter('q'));
            (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Current weather request completed');
            $response->success($result, 200);
        }, [
            'modules_weatherapi_current' => 'Get current weather data from WeatherAPI',
        ]);

        $this->get('/modules/weatherapi/forecast', function () {
            global $response;
            self::requirePermission('modules_weatherapi_forecast');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            self::requireParameters(['q']);
            self::requireType('q', self::type_string());
            $days = (int)(self::getParameter('days') ?? 1);
            $result = (new weatherapi())->forecast(self::getParameter('q'), $days);
            (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Forecast weather request completed');
            $response->success($result, 200);
        }, [
            'modules_weatherapi_forecast' => 'Get weather forecast data from WeatherAPI',
        ]);

        $this->get('/modules/weatherapi/search', function () {
            global $response;
            self::requirePermission('modules_weatherapi_search');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            self::requireParameters(['q']);
            self::requireType('q', self::type_string());
            $result = (new weatherapi())->search(self::getParameter('q'));
            (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Weather location search completed');
            $response->success($result, 200);
        }, [
            'modules_weatherapi_search' => 'Search location data from WeatherAPI',
        ]);

        $this->get('/departments/weather', function () {
            global $response;
            self::requirePermission('departments_weather_get');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            $department_ids = self::parseDepartmentIdsFromRequest();
            foreach ($department_ids as $department_id) {
                self::requireDepartmentAccess((string)$department_id);
            }

            $selected_date_range = self::parseSelectedTimelineDateRangeFromRequest();
            $timeline_range = self::getDepartmentWeatherTimelineRange(
                $selected_date_range['date_from'] ?? null,
                $selected_date_range['date_to'] ?? null
            );
            $departments = self::loadDepartmentsByIds($department_ids);
            $status_targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments);
            $coordinates = self::resolveWeatherCoordinates($departments);
            $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
            $timeline = $this->withCachedDepartmentWeatherTimeline(
                $department_ids,
                $timeline_range,
                function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user, $status_targets_by_department): array {
                    $weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
                    $forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days);
                    if (is_string($forecast_result['fallback_reason'])) {
                        $fallback_message = match ($forecast_result['fallback_reason']) {
                            'invalid_department_coordinates' => 'Department weather fallback used due to missing or invalid department coordinates',
                            'weatherapi_location_not_found' => 'Department weather fallback used because WeatherAPI could not resolve the selected department location',
                            default => 'Department weather fallback used due to location lookup failure',
                        };
                        (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message);
                    }

                    return $this->buildDepartmentWeatherTimeline(
                        $department_ids,
                        $departments,
                        $forecast_result['forecast'],
                        $timeline_range,
                        $status_targets_by_department
                    );
                },
                true,
                $status_targets_by_department
            );

            (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
            $response->success($timeline, 200);
        }, [
            'departments_weather_get' => 'Get department weather timeline with washes and productivity status',
            'department_access_:id' => 'Access weather timeline for one or more specific departments',
        ]);

        $this->get('/departments/weather/hours/details', function () {
            global $response;
            self::requirePermission('departments_weather_get');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            $department_ids = self::parseDepartmentIdsFromRequest();
            foreach ($department_ids as $department_id) {
                self::requireDepartmentAccess((string)$department_id);
            }

            $slot = self::parseDepartmentWeatherHourSlotFromRequest();
            $departments = self::loadDepartmentsByIds($department_ids);
            $departments_with_employees = $this->loadDepartmentWeatherEmployeeHourDetailsByDepartment($departments, $slot['slotStart']);
            $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
            $total_hours = 0.0;
            foreach ($departments_with_employees as $department) {
                $total_hours += (float)($department['hours'] ?? 0.0);
            }

            (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_HOUR_DETAILS_GET', 'Department weather hour details fetched');
            $response->success([
                'date' => $slot['date'],
                'time' => $slot['time'],
                'slot' => $slot['slotKey'],
                'hours' => round($total_hours, 2),
                'departments' => $departments_with_employees,
            ], 200);
        }, [
            'departments_weather_get' => 'Get department weather employee hour details for one hour slot',
            'department_access_:id' => 'Access weather hour details for one or more specific departments',
        ]);

        $this->get('/departments/weather/targets', function () {
            global $response;
            self::requirePermission('departments_weather_targets_get');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            $department_ids = self::parseDepartmentIdsFromRequest();
            foreach ($department_ids as $department_id) {
                self::requireDepartmentAccess((string)$department_id);
            }

            $departments = self::loadDepartmentsByIds($department_ids);
            $targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments);
            $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);

            $data = [];
            foreach ($department_ids as $department_id) {
                $target = $this->normalizeDepartmentWeatherTarget($targets_by_department[$department_id] ?? null);
                $data[] = self::buildDepartmentWeatherTargetResponse($department_id, $target);
            }

            (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_GET', 'Department weather targets fetched');
            $response->success($data, 200);
        }, [
            'departments_weather_targets_get' => 'Get department weather productivity thresholds',
            'department_access_:id' => 'Access weather targets for one or more specific departments',
        ]);

        $this->put('/departments/weather/targets', function () {
            global $response;
            self::requirePermission('departments_weather_targets_manage');
            $user = (new authentication())->get_user();
            if (!$user) {
                $response->error('Invalid session', 400);
            }

            self::requireParameters(['department_id', 'degraded_threshold', 'healthy_threshold']);

            $department_id_raw = self::getParameter('department_id');
            if (!is_scalar($department_id_raw)) {
                $response->error('Invalid department_id value', 400);
            }
            $department_id_value = trim((string)$department_id_raw);
            if (!preg_match('/^\d+$/', $department_id_value)) {
                $response->error('Invalid department_id: ' . $department_id_value, 400);
            }
            $department_id = (int)$department_id_value;
            if ($department_id < 1) {
                $response->error('department_id must be at least 1', 400);
            }

            self::requireDepartmentAccess((string)$department_id);

            $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('degraded_threshold'));
            if ($degraded_threshold === null) {
                $response->error('degraded_threshold must be a non-negative number', 400);
            }

            $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('healthy_threshold'));
            if ($healthy_threshold === null) {
                $response->error('healthy_threshold must be a non-negative number', 400);
            }

            if ($healthy_threshold < $degraded_threshold) {
                $response->error('healthy_threshold must be greater than or equal to degraded_threshold', 400);
            }

            $department = (new departments_o())->select($department_id);
            if (!$department->exists()) {
                $response->error('Department not found', 404);
            }

            $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY, self::formatDepartmentWeatherThreshold($degraded_threshold));
            $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY, self::formatDepartmentWeatherThreshold($healthy_threshold));

            $target = [
                'degraded_threshold' => $degraded_threshold,
                'healthy_threshold' => $healthy_threshold,
            ];
            (new logs_o())->add('modules_weatherapi', (string)$department_id, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_UPDATE', 'Department weather targets updated');
            $response->success(self::buildDepartmentWeatherTargetResponse($department_id, $target), 200);
        }, [
            'departments_weather_targets_manage' => 'Manage department weather productivity thresholds',
            'department_access_:id' => 'Manage weather targets for a specific department',
        ]);
    }

    /**
     * Response cache TTL (seconds) for department weather timeline endpoint.
     * Set `DEPARTMENTS_WEATHER_CACHE_TTL` to override.
     */
    private function getDepartmentWeatherCacheTtl(): int
    {
        $raw = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
        if ($raw === false || trim((string)$raw) === '') {
            return 60;
        }

        return max(0, (int)$raw);
    }

    private function getDepartmentWeatherStaleCacheTtl(int $fresh_ttl): int
    {
        if ($fresh_ttl <= 0) {
            return 0;
        }

        $raw = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
        if ($raw === false || trim((string)$raw) === '') {
            return max($fresh_ttl, 300);
        }

        return max($fresh_ttl, max(0, (int)$raw));
    }

    private static function getDepartmentWeatherHotActivityTtl(): int
    {
        $raw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
        if ($raw === false || trim((string)$raw) === '') {
            return 900;
        }

        return max(1, (int)$raw);
    }

    private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range, array $status_targets_by_department = []): string
    {
        $normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int {
            return (int)$department_id;
        }, $department_ids)));
        sort($normalized_ids, SORT_NUMERIC);
        $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($normalized_ids, $status_targets_by_department);

        $start = ($timeline_range['start'] ?? null) instanceof DateTime
            ? $timeline_range['start']->format('Y-m-d H:i:s')
            : '';
        $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime
            ? $timeline_range['endExclusive']->format('Y-m-d H:i:s')
            : '';

        return 'departments_weather:timeline:v2:' . md5((string)json_encode([
            'department_ids' => $normalized_ids,
            'start' => $start,
            'end_exclusive' => $end_exclusive,
            'targets' => $this->buildDepartmentWeatherTargetsCacheKeyPayload($normalized_ids, $normalized_targets),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private static function getDepartmentWeatherHotDescriptorKey(string $hash): string
    {
        return self::DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX . $hash;
    }

    private function buildDepartmentWeatherHotDescriptor(array $department_ids, array $timeline_range): ?array
    {
        $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
        if ($normalized_ids === []) {
            return null;
        }

        $start = ($timeline_range['start'] ?? null) instanceof DateTime
            ? $timeline_range['start']->format('Y-m-d H:i:s')
            : null;
        $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime
            ? $timeline_range['endExclusive']->format('Y-m-d H:i:s')
            : null;
        if (!is_string($start) || !is_string($end_exclusive) || $start === '' || $end_exclusive === '') {
            return null;
        }

        return [
            'department_ids' => $normalized_ids,
            'start' => $start,
            'end_exclusive' => $end_exclusive,
        ];
    }

    private function normalizeDepartmentWeatherHotDescriptor(array $descriptor): ?array
    {
        $department_ids = $this->normalizeDepartmentIdsForCachePreload((array)($descriptor['department_ids'] ?? []));
        if ($department_ids === []) {
            return null;
        }

        $start_raw = $descriptor['start'] ?? null;
        $end_raw = $descriptor['end_exclusive'] ?? null;
        if (!is_string($start_raw) || !is_string($end_raw)) {
            return null;
        }

        try {
            $start = new DateTime($start_raw);
            $end_exclusive = new DateTime($end_raw);
        } catch (Exception) {
            return null;
        }

        if ($end_exclusive <= $start) {
            return null;
        }

        return [
            'department_ids' => $department_ids,
            'timeline_range' => [
                'start' => $start,
                'endExclusive' => $end_exclusive,
            ],
        ];
    }

    private function upsertDepartmentWeatherHotDescriptor(array $descriptor, int $score): ?string
    {
        if (!defined('redis')) {
            return null;
        }

        $encoded = json_encode($descriptor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($encoded) || $encoded === '') {
            return null;
        }

        $hash = md5($encoded);
        $activity_ttl = self::getDepartmentWeatherHotActivityTtl();
        $cutoff = (string)($score - $activity_ttl);

        try {
            redis->setEx(self::getDepartmentWeatherHotDescriptorKey($hash), $encoded, $activity_ttl);
            $client = redis->get_client();
            $client->zadd(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, $score, $hash);
            $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff);
        } catch (Throwable) {
            return null;
        }

        return $hash;
    }

    private function recordDepartmentWeatherHotRequest(array $department_ids, array $timeline_range): ?string
    {
        $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range);
        if ($descriptor === null) {
            return null;
        }

        return $this->upsertDepartmentWeatherHotDescriptor($descriptor, time());
    }

    private function enqueueDepartmentWeatherRefreshSignal(string $cache_key, array $department_ids, array $timeline_range): void
    {
        if (!defined('redis')) {
            return;
        }

        $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range);
        if ($descriptor === null) {
            return;
        }

        $hash = $this->upsertDepartmentWeatherHotDescriptor($descriptor, time());
        if (!is_string($hash) || $hash === '') {
            return;
        }

        try {
            $lock_key = self::DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX . md5($cache_key);
            if (!redis->set_if_absent_with_expiration($lock_key, '1', 30)) {
                return;
            }

            $activity_ttl = self::getDepartmentWeatherHotActivityTtl();
            $cutoff = (string)(time() - $activity_ttl);
            $client = redis->get_client();
            $client->zadd(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, time(), $hash);
            $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff);
        } catch (Throwable) {
            // Best-effort refresh signal enqueue.
        }
    }

    private function decodeDepartmentWeatherCachePayload(string $cached): ?array
    {
        $decoded = json_decode($cached, true);
        if (!is_array($decoded)) {
            return null;
        }

        if (isset($decoded['timeline']) && is_array($decoded['timeline'])) {
            $generated_at = null;
            if (isset($decoded['generated_at']) && is_numeric($decoded['generated_at'])) {
                $generated_at = max(0, (int)$decoded['generated_at']);
            }

            return [
                'generated_at' => $generated_at,
                'timeline' => $decoded['timeline'],
            ];
        }

        return [
            'generated_at' => null,
            'timeline' => $decoded,
        ];
    }

    private function encodeDepartmentWeatherCachePayload(array $timeline): ?string
    {
        $encoded = json_encode([
            'generated_at' => time(),
            'timeline' => $timeline,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        return is_string($encoded) ? $encoded : null;
    }

    /**
     * Best-effort Redis cache wrapper for department weather timeline payloads.
     * Falls back to direct computation when Redis is unavailable or TTL is disabled.
     *
     * @param callable():array $resolver
     * @return array
     */
    private function withCachedDepartmentWeatherTimeline(
        array $department_ids,
        array $timeline_range,
        callable $resolver,
        bool $record_hot_key = true,
        array $status_targets_by_department = []
    ): array
    {
        $fresh_ttl = $this->getDepartmentWeatherCacheTtl();
        if ($fresh_ttl <= 0 || !defined('redis')) {
            return (array)$resolver();
        }
        $stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl);

        $cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range, $status_targets_by_department);
        if ($record_hot_key) {
            $this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range);
        }

        try {
            $cached = redis->get($cache_key);
            if (is_string($cached) && $cached !== '') {
                $decoded = $this->decodeDepartmentWeatherCachePayload($cached);
                if (is_array($decoded) && isset($decoded['timeline']) && is_array($decoded['timeline'])) {
                    $age_seconds = $decoded['generated_at'] === null
                        ? ($fresh_ttl + 1)
                        : max(0, time() - (int)$decoded['generated_at']);
                    if ($age_seconds <= $stale_ttl) {
                        if ($age_seconds > $fresh_ttl && $record_hot_key) {
                            $this->enqueueDepartmentWeatherRefreshSignal($cache_key, $department_ids, $timeline_range);
                        }

                        return $decoded['timeline'];
                    }
                }
            }
        } catch (Throwable) {
            // Best-effort cache read.
        }

        $result = (array)$resolver();

        try {
            $encoded = $this->encodeDepartmentWeatherCachePayload($result);
            if (is_string($encoded)) {
                redis->setEx($cache_key, $encoded, $stale_ttl);
            }
        } catch (Throwable) {
            // Best-effort cache write.
        }

        return $result;
    }

    /**
     * @return array<int,array{department_ids:array<int>,timeline_range:array{start:DateTime,endExclusive:DateTime}}>
     */
    public static function getDepartmentWeatherHotPreloadTargets(int $limit, int $activity_ttl): array
    {
        if (!defined('redis')) {
            return [];
        }

        $limit = max(1, $limit);
        $activity_ttl = max(1, $activity_ttl);
        $route = new self();
        $targets = [];
        $seen_hashes = [];

        try {
            $client = redis->get_client();
            $cutoff = (string)(time() - $activity_ttl);
            $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff);
            $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff);

            $candidate_hashes = [];
            foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, 0, max(0, $limit - 1)) as $hash) {
                $hash = trim((string)$hash);
                if ($hash === '' || isset($seen_hashes[$hash])) {
                    continue;
                }
                $seen_hashes[$hash] = true;
                $candidate_hashes[] = $hash;
                $client->zrem(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, $hash);
            }

            if (count($candidate_hashes) < $limit) {
                $spill_limit = max($limit * 5, $limit);
                foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, 0, max(0, $spill_limit - 1)) as $hash) {
                    $hash = trim((string)$hash);
                    if ($hash === '' || isset($seen_hashes[$hash])) {
                        continue;
                    }
                    $seen_hashes[$hash] = true;
                    $candidate_hashes[] = $hash;
                    if (count($candidate_hashes) >= $spill_limit) {
                        break;
                    }
                }
            }

            foreach ($candidate_hashes as $hash) {
                $payload = redis->get(self::getDepartmentWeatherHotDescriptorKey($hash));
                if (!is_string($payload) || $payload === '') {
                    continue;
                }

                $decoded = json_decode($payload, true);
                if (!is_array($decoded)) {
                    continue;
                }

                $target = $route->normalizeDepartmentWeatherHotDescriptor($decoded);
                if ($target === null) {
                    continue;
                }

                $targets[] = $target;
                if (count($targets) >= $limit) {
                    break;
                }
            }
        } catch (Throwable) {
            return [];
        }

        return $targets;
    }

    /**
     * Pre-compute and cache a department weather timeline for background preloading jobs.
     *
     * @return array{warmed:bool,department_ids:array<int>,cache_key:string|null,entries:int}
     * @throws Exception
     */
    public static function preloadDepartmentWeatherTimelineCache(
        array $department_ids,
        ?string $date_from = null,
        ?string $date_to = null,
        ?array $timeline_range_override = null
    ): array
    {
        $route = new self();
        $normalized_ids = $route->normalizeDepartmentIdsForCachePreload($department_ids);
        if ($normalized_ids === []) {
            return [
                'warmed' => false,
                'department_ids' => [],
                'cache_key' => null,
                'entries' => 0,
            ];
        }

        if (
            is_array($timeline_range_override)
            && ($timeline_range_override['start'] ?? null) instanceof DateTime
            && ($timeline_range_override['endExclusive'] ?? null) instanceof DateTime
        ) {
            $timeline_range = [
                'start' => clone $timeline_range_override['start'],
                'endExclusive' => clone $timeline_range_override['endExclusive'],
            ];
        } else {
            if (($date_from === null) xor ($date_to === null)) {
                $date_from = null;
                $date_to = null;
            }

            $timeline_range = $route->getDepartmentWeatherTimelineRange($date_from, $date_to);
        }

        $departments = $route->loadDepartmentsByIds($normalized_ids);
        $status_targets_by_department = $route->loadDepartmentWeatherTargetsByDepartmentId($departments);
        $coordinates = $route->resolveWeatherCoordinates($departments);
        $timeline = $route->withCachedDepartmentWeatherTimeline(
            $normalized_ids,
            $timeline_range,
            function () use ($coordinates, $normalized_ids, $departments, $timeline_range, $status_targets_by_department, $route): array {
                $weather_days = $route->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
                $forecast_result = $route->fetchDepartmentForecastOrFallback($coordinates, $weather_days);

                return $route->buildDepartmentWeatherTimeline(
                    $normalized_ids,
                    $departments,
                    $forecast_result['forecast'],
                    $timeline_range,
                    $status_targets_by_department
                );
            },
            false,
            $status_targets_by_department
        );

        return [
            'warmed' => true,
            'department_ids' => $normalized_ids,
            'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range, $status_targets_by_department),
            'entries' => count($timeline),
        ];
    }

    /**
     * @param array<mixed> $department_ids
     * @return array<int>
     */
    private function normalizeDepartmentIdsForCachePreload(array $department_ids): array
    {
        $normalized = [];
        foreach ($department_ids as $department_id) {
            if (!is_numeric($department_id)) {
                continue;
            }

            $value = (int)$department_id;
            if ($value < 1) {
                continue;
            }
            $normalized[$value] = true;
        }

        $ids = array_map('intval', array_keys($normalized));
        sort($ids, SORT_NUMERIC);

        return $ids;
    }

    private function parseDepartmentIdsFromRequest(): array
    {
        global $response;

        $parameters = self::getParametersAsArray();
        $raw_ids = [];

        if (array_key_exists('id', $parameters)) {
            $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['id']));
        }
        if (array_key_exists('ids', $parameters)) {
            $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['ids']));
        }

        if ($raw_ids === []) {
            $response->error('Missing required parameters: id', 400);
        }

        $department_ids = [];
        foreach ($raw_ids as $raw_id) {
            if (is_array($raw_id) || is_object($raw_id)) {
                $response->error('Invalid department id value', 400);
            }

            $value = trim((string)$raw_id);
            if ($value === '') {
                continue;
            }
            if (!preg_match('/^\d+$/', $value)) {
                $response->error('Invalid department id: ' . $value, 400);
            }

            $department_id = (int)$value;
            if ($department_id < 1) {
                $response->error('Parameter must be at least 1', 400);
            }

            $department_ids[] = $department_id;
        }

        $department_ids = array_values(array_unique($department_ids));
        if ($department_ids === []) {
            $response->error('Missing required parameters: id', 400);
        }

        return $department_ids;
    }

    private function normalizeDepartmentIdInput(mixed $value): array
    {
        if (is_array($value)) {
            $result = [];
            foreach ($value as $item) {
                $result = array_merge($result, self::normalizeDepartmentIdInput($item));
            }
            return $result;
        }

        if (is_string($value) && str_contains($value, ',')) {
            $parts = array_map('trim', explode(',', $value));
            return array_values(array_filter($parts, static function (string $part): bool {
                return $part !== '';
            }));
        }

        return [$value];
    }

    private function parseSelectedTimelineDateRangeFromRequest(): ?array
    {
        $parameters = self::getParametersAsArray();
        $has_date_from = array_key_exists('date_from', $parameters);
        $has_date_to = array_key_exists('date_to', $parameters);

        if (!$has_date_from && !$has_date_to) {
            return null;
        }

        global $response;
        if (!$has_date_from || !$has_date_to) {
            $missing = [];
            if (!$has_date_from) {
                $missing[] = 'date_from';
            }
            if (!$has_date_to) {
                $missing[] = 'date_to';
            }
            $response->error('Missing required parameters: ' . implode(', ', $missing), 400);
        }

        $date_from_value = self::getParameter('date_from');
        $date_to_value = self::getParameter('date_to');
        if (!is_string($date_from_value) || !is_string($date_to_value)) {
            $response->error('Invalid type. Expected: string for date_from/date_to', 400);
        }

        $date_from = trim((string)$date_from_value);
        $date_to = trim((string)$date_to_value);
        self::requireDateFormat($date_from, self::FORMAT_DATE());
        self::requireDateFormat($date_to, self::FORMAT_DATE());

        $from_dt = new DateTime($date_from . ' 00:00:00');
        $to_dt = new DateTime($date_to . ' 00:00:00');
        if ($to_dt < $from_dt) {
            $response->error('Invalid date range. date_to must be on or after date_from', 400);
        }

        return [
            'date_from' => $date_from,
            'date_to' => $date_to,
        ];
    }

    private function parseDepartmentWeatherHourSlotFromRequest(): array
    {
        global $response;

        self::requireParameters(['date', 'time']);
        $date_value = self::getParameter('date');
        $time_value = self::getParameter('time');
        if (!is_string($date_value) || !is_string($time_value)) {
            $response->error('Invalid type. Expected: string for date/time', 400);
        }

        $date = trim((string)$date_value);
        $time = trim((string)$time_value);
        self::requireDateFormat($date, self::FORMAT_DATE());
        if (!preg_match('/^\d{2}:\d{2}(?::\d{2})?$/', $time)) {
            $response->error('Invalid time format. Expected HH:MM', 400);
        }

        $time_parts = explode(':', $time);
        $hour = (int)($time_parts[0] ?? -1);
        $minute = (int)($time_parts[1] ?? -1);
        if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
            $response->error('Invalid time value', 400);
        }

        $normalized_time = sprintf('%02d:%02d', $hour, $minute);
        $slot_start = new DateTime($date . ' ' . $normalized_time . ':00');
        $slot_end = clone $slot_start;
        $slot_end->add(new DateInterval('PT1H'));

        return [
            'date' => $date,
            'time' => $normalized_time,
            'slotKey' => $slot_start->format('Y-m-d H:00'),
            'slotStart' => $slot_start,
            'slotEnd' => $slot_end,
        ];
    }

    private function loadDepartmentsByIds(array $department_ids): array
    {
        $departments = [];
        foreach ($department_ids as $department_id) {
            $departments[] = (new departments_o())->select((int)$department_id);
        }

        return $departments;
    }

    private static function normalizeDepartmentWeatherThresholdValue(mixed $value): ?float
    {
        if (is_string($value)) {
            $value = trim($value);
            if ($value === '') {
                return null;
            }
        }

        if (!is_numeric($value)) {
            return null;
        }

        $threshold = (float)$value;
        if (!is_finite($threshold) || $threshold < 0) {
            return null;
        }

        return round($threshold, 6);
    }

    private static function formatDepartmentWeatherThreshold(float $threshold): string
    {
        $formatted = number_format($threshold, 6, '.', '');
        $trimmed = rtrim(rtrim($formatted, '0'), '.');

        return $trimmed === '' ? '0' : $trimmed;
    }

    private function normalizeDepartmentWeatherTarget(mixed $target): ?array
    {
        if (!is_array($target)) {
            return null;
        }

        $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue($target['degraded_threshold'] ?? null);
        $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue($target['healthy_threshold'] ?? null);
        if ($degraded_threshold === null || $healthy_threshold === null) {
            return null;
        }
        if ($healthy_threshold < $degraded_threshold) {
            return null;
        }

        return [
            'degraded_threshold' => $degraded_threshold,
            'healthy_threshold' => $healthy_threshold,
        ];
    }

    private function normalizeDepartmentWeatherTargetsByDepartmentId(array $department_ids, array $status_targets_by_department): array
    {
        $normalized = [];
        foreach ($department_ids as $department_id) {
            $normalized_department_id = (int)$department_id;
            if ($normalized_department_id < 1) {
                continue;
            }

            $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
            if ($target !== null) {
                $normalized[$normalized_department_id] = $target;
            }
        }

        ksort($normalized, SORT_NUMERIC);

        return $normalized;
    }

    private function loadDepartmentWeatherTargetsByDepartmentId(array $departments): array
    {
        $targets_by_department = [];
        foreach ($departments as $department) {
            $department_id = (int)($department->id ?? 0);
            if ($department_id < 1) {
                continue;
            }

            $target = $this->normalizeDepartmentWeatherTarget([
                'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD,
                'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD,
            ]);
            if ($target !== null) {
                $targets_by_department[$department_id] = $target;
            }
        }

        ksort($targets_by_department, SORT_NUMERIC);

        return $targets_by_department;
    }

    private static function buildDepartmentWeatherTargetResponse(int $department_id, ?array $target): array
    {
        return [
            'department_id' => $department_id,
            'degraded_threshold' => $target['degraded_threshold'] ?? null,
            'healthy_threshold' => $target['healthy_threshold'] ?? null,
            'configured' => is_array($target),
        ];
    }

    private function buildDepartmentWeatherTargetsCacheKeyPayload(array $department_ids, array $status_targets_by_department): array
    {
        $payload = [];
        foreach ($department_ids as $department_id) {
            $normalized_department_id = (int)$department_id;
            if ($normalized_department_id < 1) {
                continue;
            }

            $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
            $payload[(string)$normalized_department_id] = $target;
        }

        ksort($payload, SORT_STRING);

        return $payload;
    }

    private static function sumDepartmentHoursForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): float
    {
        $total = 0.0;
        foreach ($department_ids as $department_id) {
            $normalized_department_id = (int)$department_id;
            if ($normalized_department_id < 1) {
                continue;
            }

            $total += (float)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0);
        }

        return $total;
    }

    private static function sumDepartmentWashesForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): int
    {
        $total = 0;
        foreach ($department_ids as $department_id) {
            $normalized_department_id = (int)$department_id;
            if ($normalized_department_id < 1) {
                continue;
            }

            $total += (int)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0);
        }

        return $total;
    }

    private function resolveWeatherCoordinates(array $departments): ?array
    {
        $lat_sum = 0.0;
        $lon_sum = 0.0;
        $count = 0;

        foreach ($departments as $department) {
            $lat = self::normalizeCoordinateValue($department->latitude->value(), -90.0, 90.0);
            $lon = self::normalizeCoordinateValue($department->longitude->value(), -180.0, 180.0);
            if ($lat === null || $lon === null) {
                continue;
            }
            if ($lat === 0.0 && $lon === 0.0) {
                continue;
            }

            $lat_sum += $lat;
            $lon_sum += $lon;
            $count++;
        }

        if ($count === 0) {
            return null;
        }

        return [
            'lat' => $lat_sum / $count,
            'lon' => $lon_sum / $count,
        ];
    }

    /**
     * @throws Exception
     */
    private function fetchDepartmentForecastOrFallback(?array $coordinates, int $weather_days): array
    {
        if ($coordinates === null) {
            return [
                'forecast' => self::createEmptyForecastPayload(),
                'fallback_reason' => 'invalid_department_coordinates',
            ];
        }

        try {
            return [
                'forecast' => (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], $weather_days),
                'fallback_reason' => null,
            ];
        } catch (Exception $exception) {
            if (!self::isWeatherLocationLookupFailure($exception)) {
                throw $exception;
            }

            return [
                'forecast' => self::createEmptyForecastPayload(),
                'fallback_reason' => 'weatherapi_location_not_found',
            ];
        }
    }

    private function createEmptyForecastPayload(): object
    {
        return (object)[
            'forecast' => (object)[
                'forecastday' => [],
            ],
        ];
    }

    private function isWeatherLocationLookupFailure(Exception $exception): bool
    {
        $message = strtolower(trim($exception->getMessage()));

        return str_contains($message, 'weatherapi request failed with http 400')
            && str_contains($message, 'no matching location found');
    }

    private function normalizeCoordinateValue(mixed $value, float $min, float $max): ?float
    {
        if ($value === null) {
            return null;
        }

        if (is_string($value)) {
            $value = trim($value);
            if ($value === '') {
                return null;
            }
        }

        if (!is_numeric($value)) {
            return null;
        }

        $coordinate = (float)$value;
        if (!is_finite($coordinate)) {
            return null;
        }
        if ($coordinate < $min || $coordinate > $max) {
            return null;
        }

        return $coordinate;
    }

    /**
     * @throws Exception
     */
    private function buildDepartmentWeatherTimeline(
        array $department_ids,
        array $departments,
        object $forecast,
        array $timeline_range,
        array $status_targets_by_department = []
    ): array
    {
        $hourly_weather = [];
        foreach (($forecast->forecast->forecastday ?? []) as $day) {
            foreach (($day->hour ?? []) as $hour) {
                $key = (new DateTime((string)$hour->time))->format('Y-m-d H:00');
                $hourly_weather[$key] = self::mapWeatherCondition((int)($hour->condition->code ?? 1000), (string)($hour->condition->text ?? ''));
            }
        }

        $timeline_start = $timeline_range['start'];
        $timeline_end_exclusive = $timeline_range['endExclusive'];
        $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($department_ids, $status_targets_by_department);
        $normalized_department_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);

        $entries = [];
        $slot = clone $timeline_start;
        $workfeed_hours_by_department_and_slot = $departments === []
            ? []
            : self::loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive);
        $washes_by_department_and_slot = $normalized_department_ids === []
            ? []
            : self::loadDepartmentWashCountsBySlotByDepartment($normalized_department_ids, $timeline_start, $timeline_end_exclusive);
        $current_slot_start = new DateTime(date('Y-m-d H:00:00'));
        $current_slot_key = $current_slot_start->format('Y-m-d H:00');

        while ($slot < $timeline_end_exclusive) {
            $slot_key = $slot->format('Y-m-d H:00');
            $weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
            $hours = self::sumDepartmentHoursForSlot($normalized_department_ids, $workfeed_hours_by_department_and_slot, $slot_key);
            $washes = self::sumDepartmentWashesForSlot($normalized_department_ids, $washes_by_department_and_slot, $slot_key);
            $slot_started = $slot <= $current_slot_start;
            $entries[] = [
                'date' => $slot->format('Y-m-d'),
                'time' => $slot->format('H:00'),
                'current' => $slot_key === $current_slot_key,
                'weather' => $weather,
                'washes' => $washes,
                'hours' => $hours,
                'status' => self::calculateAggregatedDepartmentStatus(
                    $normalized_department_ids,
                    $washes_by_department_and_slot,
                    $workfeed_hours_by_department_and_slot,
                    $slot_key,
                    $slot_started,
                    $normalized_targets
                ),
            ];
            $slot->add(new DateInterval('PT1H'));
        }

        return $entries;
    }

    private function calculateStatus(int $washes, float $hours, bool $slot_started = true, ?array $targets = null): string
    {
        if (!$slot_started) {
            return 'unknown';
        }

        if ($hours <= 0) {
            return 'unknown';
        }

        $normalized_targets = $this->normalizeDepartmentWeatherTarget($targets);
        if ($normalized_targets === null) {
            return 'unknown';
        }

        $ratio = $washes / $hours;
        if ($ratio >= $normalized_targets['healthy_threshold']) {
            return 'healthy';
        }
        if ($ratio >= $normalized_targets['degraded_threshold']) {
            return 'degraded';
        }

        return 'unhealthy';
    }

    private function calculateAggregatedDepartmentStatus(
        array $department_ids,
        array $washes_by_department_and_slot,
        array $hours_by_department_and_slot,
        string $slot_key,
        bool $slot_started,
        array $status_targets_by_department
    ): string
    {
        if (!$slot_started) {
            return 'unknown';
        }

        $worst_status = 'unknown';
        $worst_severity = 0;
        $has_evaluable_department = false;

        foreach ($department_ids as $department_id) {
            $normalized_department_id = (int)$department_id;
            if ($normalized_department_id < 1) {
                continue;
            }

            $targets = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null);
            if ($targets === null) {
                return 'unknown';
            }

            $hours = (float)($hours_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0);
            if ($hours <= 0) {
                continue;
            }

            $has_evaluable_department = true;
            $washes = (int)($washes_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0);
            $status = $this->calculateStatus($washes, $hours, true, $targets);
            $severity = self::DEPARTMENT_WEATHER_STATUS_SEVERITY[$status] ?? 0;
            if ($severity > $worst_severity) {
                $worst_severity = $severity;
                $worst_status = $status;
            }
        }

        if (!$has_evaluable_department) {
            return 'unknown';
        }

        return $worst_status;
    }

    /**
     * @throws Exception
     */
    private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
    {
        $hours_by_department_and_slot = $this->loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive);
        if ($hours_by_department_and_slot === []) {
            return [];
        }

        $hours_by_slot = [];
        foreach ($hours_by_department_and_slot as $department_hours_by_slot) {
            foreach ($department_hours_by_slot as $slot_key => $hours) {
                if (!isset($hours_by_slot[$slot_key])) {
                    $hours_by_slot[$slot_key] = 0.0;
                }
                $hours_by_slot[$slot_key] += (float)$hours;
            }
        }

        return $hours_by_slot;
    }

    /**
     * @throws Exception
     */
    private function loadWorkfeedDepartmentHoursBySlotByDepartment(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
    {
        try {
            $workfeed = new workfeed();
            $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed);
            if ($workfeed_department_ids_by_department === []) {
                return [];
            }

            $query_start = clone $timeline_start;
            $query_start->sub(new DateInterval('P1D'));
            $shifts_response = $workfeed->listShifts([
                'startFrom' => $query_start->format(DateTime::ATOM),
                'startTo' => $timeline_end_exclusive->format(DateTime::ATOM),
            ]);
            $shifts = self::normalizeWorkfeedCollection($shifts_response);
            if ($shifts === []) {
                return [];
            }

            $hours_by_department_and_slot = [];
            foreach ($workfeed_department_ids_by_department as $department_id => $workfeed_department_id) {
                $slot = clone $timeline_start;
                while ($slot < $timeline_end_exclusive) {
                    $slot_key = $slot->format('Y-m-d H:00');
                    if (!isset($hours_by_department_and_slot[$department_id])) {
                        $hours_by_department_and_slot[$department_id] = [];
                    }
                    $hours_by_department_and_slot[$department_id][$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot);
                    $slot->add(new DateInterval('PT1H'));
                }
            }

            return $hours_by_department_and_slot;
        } catch (Exception) {
            return [];
        }
    }

    private function getDepartmentWeatherTimelineRange(?string $date_from = null, ?string $date_to = null): array
    {
        if ($date_from !== null && $date_to !== null) {
            $start = new DateTime($date_from . ' 00:00:00');
            $end_exclusive = new DateTime($date_to . ' 00:00:00');
            $end_exclusive->add(new DateInterval('P1D'));
        } else {
            $start = new DateTime(date('Y-m-d 00:00:00'));
            $start->sub(new DateInterval('P1D'));

            $end_exclusive = new DateTime(date('Y-m-d 00:00:00'));
            $end_exclusive->add(new DateInterval('P1D'));
        }

        return [
            'start' => $start,
            'endExclusive' => $end_exclusive,
        ];
    }

    private function resolveForecastDaysForTimelineRange(DateTime $timeline_start, DateTime $timeline_end_exclusive): int
    {
        $today_start = new DateTime(date('Y-m-d 00:00:00'));
        $effective_start = $timeline_start->getTimestamp() > $today_start->getTimestamp() ? clone $timeline_start : $today_start;
        $seconds = max(0, $timeline_end_exclusive->getTimestamp() - $effective_start->getTimestamp());
        $days = (int)ceil($seconds / 86400);

        return max(1, min(14, $days));
    }

    private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array
    {
        $resolved_ids = array_values(array_unique(array_values($this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed))));
        sort($resolved_ids, SORT_STRING);

        return $resolved_ids;
    }

    private function resolveWorkfeedDepartmentIdsByDepartmentId(array $departments, workfeed $workfeed): array
    {
        $resolved_ids = [];
        $workfeed_departments = null;

        foreach ($departments as $department) {
            $department_id = (int)($department->id ?? 0);
            if ($department_id < 1) {
                continue;
            }

            $configured_id = self::getConfiguredWorkfeedDepartmentId($department);
            if ($configured_id !== null) {
                $resolved_ids[$department_id] = $configured_id;
                continue;
            }

            if ($workfeed_departments === null) {
                $workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments());
            }
            if ($workfeed_departments === []) {
                continue;
            }

            $department_name = trim((string)$department->name->value());
            if ($department_name === '') {
                continue;
            }

            $matched_id = self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments);
            if ($matched_id !== null) {
                $resolved_ids[$department_id] = $matched_id;
            }
        }

        return $resolved_ids;
    }

    private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string
    {
        $keys = [
            'workfeed_department_id',
            'workfeedDepartmentId',
            'workfeed_departmentID',
            'workfeed_department',
        ];

        foreach ($keys as $key) {
            $value = $department->variables->getVariable($key);
            if (!is_string($value)) {
                continue;
            }

            $trimmed = trim($value);
            if ($trimmed !== '') {
                return $trimmed;
            }
        }

        return null;
    }

    private function matchWorkfeedDepartmentIdByName(string $department_name, array $workfeed_departments): ?string
    {
        $needle = self::normalizeDepartmentName($department_name);
        foreach ($workfeed_departments as $entry) {
            $record = self::normalizeWorkfeedRecord($entry);
            $name = trim((string)($record['name'] ?? ''));
            $id = trim((string)($record['id'] ?? ''));
            if ($name === '' || $id === '') {
                continue;
            }

            if (self::normalizeDepartmentName($name) === $needle) {
                return $id;
            }
        }

        return null;
    }

    private static function normalizeDepartmentName(string $name): string
    {
        $collapsed = preg_replace('/\s+/', ' ', trim($name));

        return strtolower($collapsed ?? trim($name));
    }

    private static function normalizeWorkfeedCollection(array|object $payload): array
    {
        if (is_array($payload)) {
            return $payload;
        }

        foreach (['data', 'items', 'results', 'shifts', 'departments'] as $key) {
            if (!isset($payload->$key)) {
                continue;
            }

            $value = $payload->$key;
            if (is_array($value)) {
                return $value;
            }
            if (is_object($value)) {
                return array_values(get_object_vars($value));
            }
        }

        return [];
    }

    private static function normalizeWorkfeedRecord(mixed $record): array
    {
        if (is_array($record)) {
            return $record;
        }
        if (is_object($record)) {
            return get_object_vars($record);
        }

        return [];
    }

    private static function extractWorkfeedDepartmentId(mixed $shift): ?string
    {
        $record = self::normalizeWorkfeedRecord($shift);

        $department_id = $record['departmentID'] ?? $record['departmentId'] ?? null;
        if (($department_id === null || $department_id === '') && isset($record['department'])) {
            $department = self::normalizeWorkfeedRecord($record['department']);
            $department_id = $department['id'] ?? $department['departmentID'] ?? $department['departmentId'] ?? null;
        }

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

        $normalized = trim((string)$department_id);

        return $normalized === '' ? null : $normalized;
    }

    private static function parseDateTimeValue(mixed $value): ?DateTime
    {
        if (is_string($value)) {
            $normalized = trim($value);
            if ($normalized === '') {
                return null;
            }

            try {
                return new DateTime($normalized);
            } catch (Exception) {
                if (!is_numeric($normalized)) {
                    return null;
                }
                $value = (float)$normalized;
            }
        }

        if (is_int($value) || is_float($value)) {
            if (!is_finite((float)$value)) {
                return null;
            }
            $timestamp = (float)$value;
            if ($timestamp > 9999999999) {
                $timestamp /= 1000;
            }

            try {
                $date = new DateTime('@' . (string)(int)round($timestamp));
                $date->setTimezone(new DateTimeZone('UTC'));

                return $date;
            } catch (Exception) {
                return null;
            }
        }

        $record = self::normalizeWorkfeedRecord($value);
        if ($record !== []) {
            foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
                if (!array_key_exists($key, $record)) {
                    continue;
                }

                $parsed = self::parseDateTimeValue($record[$key]);
                if ($parsed !== null) {
                    return $parsed;
                }
            }
        }

        return null;
    }

    private static function getNestedRecordValue(array $record, string $path): mixed
    {
        $segments = explode('.', $path);
        $current = $record;

        foreach ($segments as $segment) {
            if (is_array($current)) {
                if (!array_key_exists($segment, $current)) {
                    return null;
                }
                $current = $current[$segment];
                continue;
            }

            if (is_object($current)) {
                if (!property_exists($current, $segment)) {
                    return null;
                }
                $current = $current->$segment;
                continue;
            }

            return null;
        }

        return $current;
    }

    private static function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
    {
        foreach ($paths as $path) {
            $value = self::getNestedRecordValue($record, $path);
            $parsed = self::parseDateTimeValue($value);
            if ($parsed !== null) {
                return $parsed;
            }
        }

        return null;
    }

    private static function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
    {
        $latest = null;

        foreach ($paths as $path) {
            $value = self::getNestedRecordValue($record, $path);
            $parsed = self::parseDateTimeValue($value);
            if ($parsed === null) {
                continue;
            }

            if ($latest === null || $parsed->getTimestamp() > $latest->getTimestamp()) {
                $latest = $parsed;
            }
        }

        return $latest;
    }

    private static function hasShiftApproval(array $record): bool
    {
        if (!array_key_exists('approval', $record)) {
            return false;
        }

        $approval = $record['approval'];
        if ($approval === null) {
            return false;
        }

        if (is_array($approval)) {
            return $approval !== [];
        }
        if (is_object($approval)) {
            return get_object_vars($approval) !== [];
        }

        return true;
    }

    private static function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime
    {
        if (self::hasShiftApproval($record)) {
            return $shift_end;
        }

        $update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
        if ($update_time === null) {
            return $shift_end;
        }

        $shift_start_ts = $shift_start->getTimestamp();
        $shift_end_ts = $shift_end->getTimestamp();
        $update_ts = $update_time->getTimestamp();

        if ($update_ts <= $shift_end_ts) {
            return $shift_end;
        }

        // Guard against counting late administrative edits as overtime.
        $max_unapproved_extension_seconds = 6 * 3600;
        if (($update_ts - $shift_end_ts) > $max_unapproved_extension_seconds) {
            return $shift_end;
        }
        if (($update_ts - $shift_start_ts) > 24 * 3600) {
            return $shift_end;
        }

        return $update_time;
    }

    private static function calculateWorkfeedEmployeeHoursForHour(
        array $shifts,
        string|array $workfeed_department_ids,
        DateTime $slot_start,
        ?DateTime $occurred_until = null
    ): float
    {
        $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
        $department_id_lookup = [];
        foreach ($department_id_values as $department_id_value) {
            $normalized = trim((string)$department_id_value);
            if ($normalized !== '') {
                $department_id_lookup[$normalized] = true;
            }
        }
        if ($department_id_lookup === []) {
            return 0.0;
        }

        $slot_end = clone $slot_start;
        $slot_end->add(new DateInterval('PT1H'));
        $slot_start_ts = $slot_start->getTimestamp();
        $slot_end_ts = $slot_end->getTimestamp();
        $occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();

        $hours = 0.0;
        foreach ($shifts as $shift) {
            $shift_department_id = self::extractWorkfeedDepartmentId($shift);
            if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) {
                continue;
            }

            $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
            if ($timing === null) {
                continue;
            }

            $shift_start_ts = $timing['actualStart']->getTimestamp();
            $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
            if ($shift_end_ts <= $shift_start_ts) {
                continue;
            }

            $overlap_start = max($slot_start_ts, $shift_start_ts);
            $overlap_end = min($slot_end_ts, $shift_end_ts);
            if ($overlap_end > $overlap_start) {
                $hours += ($overlap_end - $overlap_start) / 3600;
            }
        }

        return round($hours, 2);
    }

    /**
     * @throws Exception
     */
    private function loadDepartmentWeatherEmployeeHourDetailsByDepartment(array $departments, DateTime $slot_start): array
    {
        try {
            $workfeed = new workfeed();
            $employeeNameCache = null;
            if (defined('redis')) {
                try {
                    $employeeNameCache = new redis();
                } catch (Exception $e) {
                    // Redis is unavailable, proceed without caching.
                }
            }

            $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed);
            if ($workfeed_department_ids_by_department === []) {
                return [];
            }

            $slot_end = clone $slot_start;
            $slot_end->add(new DateInterval('PT1H'));
            $query_start = clone $slot_start;
            $query_start->sub(new DateInterval('P1D'));

            $shifts_response = $workfeed->listShifts([
                'startFrom' => $query_start->format(DateTime::ATOM),
                'startTo' => $slot_end->format(DateTime::ATOM),
            ]);
            $shifts = self::normalizeWorkfeedCollection($shifts_response);
            if ($shifts === []) {
                return [];
            }

            $occurred_until = new DateTime();
            $resolved_employee_names_by_id = [];
            $details = [];
            foreach ($departments as $department) {
                $department_id = (int)($department->id ?? 0);
                if ($department_id < 1) {
                    continue;
                }

                $workfeed_department_ids = $workfeed_department_ids_by_department[$department_id] ?? null;
                if ($workfeed_department_ids === null) {
                    continue;
                }

                $employees = self::calculateWorkfeedEmployeeHoursForHourByEmployee(
                    $shifts,
                    $workfeed_department_ids,
                    $slot_start,
                    $occurred_until
                );
                $employees = $this->resolveMissingWorkfeedEmployeeNames(
                    $employees,
                    $employeeNameCache,
                    $resolved_employee_names_by_id,
                    $workfeed
                );
                if ($employees === []) {
                    continue;
                }

                $department_hours = 0.0;
                foreach ($employees as $employee) {
                    $department_hours += (float)($employee['hours'] ?? 0.0);
                }

                $details[] = [
                    'department_id' => $department_id,
                    'department_name' => trim((string)($department->name ?? '')) ?: ('Department ' . $department_id),
                    'hours' => round($department_hours, 2),
                    'employees' => $employees,
                ];
            }

            usort($details, static function (array $left, array $right): int {
                return strcasecmp((string)($left['department_name'] ?? ''), (string)($right['department_name'] ?? ''));
            });

            return $details;
        } catch (Exception) {
            return [];
        }
    }

    private static function calculateWorkfeedEmployeeHoursForHourByEmployee(
        array $shifts,
        string|array $workfeed_department_ids,
        DateTime $slot_start,
        ?DateTime $occurred_until = null
    ): array
    {
        $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
        $department_id_lookup = [];
        foreach ($department_id_values as $department_id_value) {
            $normalized = trim((string)$department_id_value);
            if ($normalized !== '') {
                $department_id_lookup[$normalized] = true;
            }
        }
        if ($department_id_lookup === []) {
            return [];
        }

        $slot_end = clone $slot_start;
        $slot_end->add(new DateInterval('PT1H'));
        $slot_start_ts = $slot_start->getTimestamp();
        $slot_end_ts = $slot_end->getTimestamp();
        $occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();

        $hours_by_employee_key = [];
        foreach ($shifts as $shift) {
            $shift_department_id = self::extractWorkfeedDepartmentId($shift);
            if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) {
                continue;
            }

            $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
            if ($timing === null) {
                continue;
            }

            $shift_start_ts = $timing['actualStart']->getTimestamp();
            $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
            if ($shift_end_ts <= $shift_start_ts) {
                continue;
            }

            $overlap_start = max($slot_start_ts, $shift_start_ts);
            $overlap_end = min($slot_end_ts, $shift_end_ts);
            if ($overlap_end <= $overlap_start) {
                continue;
            }

            $employee_identity = self::extractWorkfeedEmployeeIdentity($shift);
            $employee_id = $employee_identity['id'];
            $employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null);
            if ($employee_id === null && $employee_name === null) {
                continue;
            }

            $employee_key = $employee_id ?? ('name:' . strtolower($employee_name));
            if (!isset($hours_by_employee_key[$employee_key])) {
                $hours_by_employee_key[$employee_key] = [
                    'employee_id' => $employee_id,
                    'employee_name' => $employee_name,
                    'hours' => 0.0,
                ];
            }

            $hours_by_employee_key[$employee_key]['hours'] += ($overlap_end - $overlap_start) / 3600;
        }

        foreach ($hours_by_employee_key as &$employee) {
            $employee['hours'] = round((float)$employee['hours'], 2);
        }
        unset($employee);

        usort($hours_by_employee_key, static function (array $left, array $right): int {
            return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? ''));
        });

        return array_values(array_filter($hours_by_employee_key, static function (array $employee): bool {
            return (float)($employee['hours'] ?? 0.0) > 0;
        }));
    }

    private function resolveMissingWorkfeedEmployeeNames(
        array $employees,
        ?redis $employee_name_cache,
        array &$resolved_names_by_employee_id,
        ?workfeed $workfeed = null
    ): array
    {
        $ids_to_fetch = [];
        foreach ($employees as &$employee) {
            $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
            if ($employee_id === null) {
                continue;
            }

            $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);
            if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) {
                continue;
            }

            if (!array_key_exists($employee_id, $resolved_names_by_employee_id)) {
                $resolved_names_by_employee_id[$employee_id] = $this->fetchCachedWorkfeedEmployeeDisplayName(
                    $employee_name_cache,
                    $employee_id
                );
            }

            if ($resolved_names_by_employee_id[$employee_id] === null) {
                $ids_to_fetch[$employee_id] = true;
            }
        }
        unset($employee);

        if ($ids_to_fetch !== [] && $workfeed !== null) {
            $api_names_by_employee_id = $this->fetchWorkfeedEmployeeDisplayNames(
                $workfeed,
                array_keys($ids_to_fetch),
                $employee_name_cache
            );

            foreach (array_keys($ids_to_fetch) as $employee_id) {
                $resolved_names_by_employee_id[$employee_id] = $api_names_by_employee_id[$employee_id] ?? null;
            }
        }

        foreach ($employees as &$employee) {
            $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
            if ($employee_id === null) {
                continue;
            }

            $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);
            if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) {
                continue;
            }

            $resolved_name = $resolved_names_by_employee_id[$employee_id] ?? null;
            if ($resolved_name !== null) {
                $employee['employee_name'] = $resolved_name;
            }
        }
        unset($employee);

        usort($employees, static function (array $left, array $right): int {
            return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? ''));
        });

        return array_values(array_filter($employees, static function (array $employee): bool {
            $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
            $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);

            return !self::isMissingEmployeeDisplayName($employee_name, $employee_id);
        }));
    }

    /**
     * @param array<int,string> $employee_ids
     * @return array<string,string>
     */
    private function fetchWorkfeedEmployeeDisplayNames(workfeed $workfeed, array $employee_ids, ?redis $employee_name_cache): array
    {
        $employee_id_lookup = [];
        foreach ($employee_ids as $employee_id) {
            $normalized = self::normalizeShiftTextValue($employee_id);
            if ($normalized !== null) {
                $employee_id_lookup[$normalized] = true;
            }
        }
        if ($employee_id_lookup === []) {
            return [];
        }

        $names_by_employee_id = [];
        try {
            $employees = self::normalizeWorkfeedCollection($workfeed->listEmployees());
        } catch (Exception) {
            $employees = [];
        }

        foreach ($employees as $employee) {
            $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache);
        }

        foreach (array_keys($employee_id_lookup) as $employee_id) {
            if (isset($names_by_employee_id[$employee_id])) {
                continue;
            }

            try {
                $employee = $workfeed->getEmployee($employee_id);
            } catch (Exception) {
                continue;
            }

            $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache);
        }

        return $names_by_employee_id;
    }

    /**
     * @param array<string,bool> $employee_id_lookup
     * @param array<string,string> $names_by_employee_id
     */
    private function appendWorkfeedEmployeeDisplayNames(
        mixed $employee,
        array $employee_id_lookup,
        array &$names_by_employee_id,
        ?redis $employee_name_cache
    ): void {
        foreach (self::extractWorkfeedEmployeeIds($employee) as $employee_id) {
            if (!isset($employee_id_lookup[$employee_id])) {
                continue;
            }

            $employee_name = self::extractWorkfeedEmployeeDisplayName($employee, $employee_id);
            if ($employee_name === null) {
                continue;
            }

            $names_by_employee_id[$employee_id] = $employee_name;
            $this->cacheWorkfeedEmployeeDisplayName($employee_name_cache, $employee_id, $employee_name);
        }
    }

    private static function extractWorkfeedEmployeeDisplayName(mixed $employee, ?string $employee_id = null): ?string
    {
        return workfeed_employee_name_formatter::fromRecord($employee, [
            'firstname',
            'firstName',
            'first_name',
            'employee.firstname',
            'employee.firstName',
            'employee.first_name',
            'user.firstname',
            'user.firstName',
            'user.first_name',
        ], [
            'lastname',
            'lastName',
            'last_name',
            'employee.lastname',
            'employee.lastName',
            'employee.last_name',
            'user.lastname',
            'user.lastName',
            'user.last_name',
        ], [
            'employeeName',
            'employee.name',
            'employee.fullName',
            'employee.full_name',
            'employee.displayName',
            'employee.display_name',
            'name',
            'fullName',
            'full_name',
            'displayName',
            'display_name',
            'user.name',
            'user.fullName',
            'user.full_name',
            'user.displayName',
            'user.display_name',
        ], $employee_id);
    }

    /**
     * @return array<int,string>
     */
    private static function extractWorkfeedEmployeeIds(mixed $employee): array
    {
        $record = self::normalizeWorkfeedRecord($employee);
        if ($record === []) {
            return [];
        }

        $employee_ids = [];
        foreach ([
            'id',
            'employeeID',
            'employeeId',
            'employee_id',
            'uuid',
            'employee.id',
            'employee.employeeID',
            'employee.employeeId',
            'employee.employee_id',
            'employee.uuid',
            'employeeUUID',
            'employee_uuid',
            'user.id',
            'userId',
        ] as $path) {
            $employee_id = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
            if ($employee_id !== null) {
                $employee_ids[$employee_id] = true;
            }
        }

        return array_keys($employee_ids);
    }

    private function cacheWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id, string $employee_name): void
    {
        if ($employee_name_cache === null) {
            return;
        }

        try {
            $employee_name_cache->cache_workfeed_employee_name($employee_id, $employee_name);
        } catch (Exception) {
        }
    }

    private function fetchCachedWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id): ?string
    {
        if ($employee_name_cache === null) {
            return null;
        }

        try {
            $employee_name = $employee_name_cache->get_workfeed_employee_name($employee_id);
        } catch (Exception) {
            return null;
        }

        return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name;
    }

    private static function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool
    {
        return workfeed_employee_name_formatter::isMissingDisplayName($employee_name, $employee_id);
    }

    private static function extractWorkfeedEmployeeIdentity(mixed $shift): array
    {
        $record = self::normalizeWorkfeedRecord($shift);

        $employee_id = null;
        foreach ([
            'employeeID',
            'employeeId',
            'employee_id',
            'employee.id',
            'employee.employeeID',
            'employee.employeeId',
            'employee.employee_id',
            'employee.uuid',
            'employeeUUID',
            'employee_uuid',
            'user.id',
            'userId',
        ] as $path) {
            $value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
            if ($value !== null) {
                $employee_id = $value;
                break;
            }
        }

        $employee_name = workfeed_employee_name_formatter::fromRecord($record, [
            'employee.firstname',
            'employee.firstName',
            'employee.first_name',
            'firstname',
            'firstName',
            'first_name',
            'user.firstname',
            'user.firstName',
            'user.first_name',
        ], [
            'employee.lastname',
            'employee.lastName',
            'employee.last_name',
            'lastname',
            'lastName',
            'last_name',
            'user.lastname',
            'user.lastName',
            'user.last_name',
        ], [
            'employeeName',
            'employee.name',
            'employee.fullName',
            'employee.full_name',
            'employee.displayName',
            'employee.display_name',
            'name',
            'fullName',
            'full_name',
            'displayName',
            'display_name',
            'user.name',
            'user.fullName',
            'user.full_name',
            'user.displayName',
            'user.display_name',
        ], $employee_id);

        return [
            'id' => $employee_id,
            'name' => $employee_name,
        ];
    }

    private static function normalizeShiftTextValue(mixed $value): ?string
    {
        if (!is_scalar($value)) {
            return null;
        }

        $normalized = trim((string)$value);
        if ($normalized === '') {
            return null;
        }

        return $normalized;
    }

    /**
     * @throws Exception
     */
    private function loadDepartmentWashCountsBySlot(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
    {
        $counts_by_department_and_slot = $this->loadDepartmentWashCountsBySlotByDepartment($department_ids, $timeline_start, $timeline_end_exclusive);
        if ($counts_by_department_and_slot === []) {
            return [];
        }

        $counts = [];
        foreach ($counts_by_department_and_slot as $department_counts_by_slot) {
            foreach ($department_counts_by_slot as $slot_key => $wash_count) {
                if (!isset($counts[$slot_key])) {
                    $counts[$slot_key] = 0;
                }
                $counts[$slot_key] += (int)$wash_count;
            }
        }

        return $counts;
    }

    /**
     * @throws Exception
     */
    private function loadDepartmentWashCountsBySlotByDepartment(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
    {
        $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
        if ($normalized_ids === []) {
            return [];
        }

        $range_end = clone $timeline_end_exclusive;
        $range_end->sub(new DateInterval('PT1S'));
        if ($range_end < $timeline_start) {
            return [];
        }

        try {
            $rows = (new orders_o())->countWashesByHourForDepartments(
                $timeline_start->format('Y-m-d H:i:s'),
                $range_end->format('Y-m-d H:i:s'),
                $normalized_ids
            );

            return $this->normalizeDepartmentWashCountRowsByDepartment($rows);
        } catch (Exception) {
            return [];
        }
    }

    private function normalizeDepartmentWashCountRows(array $rows): array
    {
        $counts_by_department_and_slot = $this->normalizeDepartmentWashCountRowsByDepartment($rows);

        $counts = [];
        foreach ($counts_by_department_and_slot as $department_counts_by_slot) {
            foreach ($department_counts_by_slot as $slot_key => $wash_count) {
                if (!isset($counts[$slot_key])) {
                    $counts[$slot_key] = 0;
                }
                $counts[$slot_key] += (int)$wash_count;
            }
        }

        return $counts;
    }

    private function normalizeDepartmentWashCountRowsByDepartment(array $rows): array
    {
        $counts = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $department_id = (int)($row['department_id'] ?? $row['departmentId'] ?? $row['department'] ?? 0);
            if ($department_id < 1) {
                continue;
            }

            $bucket = trim((string)($row['hour_bucket'] ?? $row['hour'] ?? $row['slot'] ?? ''));
            if ($bucket === '') {
                continue;
            }

            try {
                $slot_key = (new DateTime($bucket))->format('Y-m-d H:00');
            } catch (Exception) {
                continue;
            }

            $wash_count = (int)($row['wash_count'] ?? $row['count'] ?? 0);
            if ($wash_count < 0) {
                $wash_count = 0;
            }

            if (!isset($counts[$department_id])) {
                $counts[$department_id] = [];
            }
            if (!isset($counts[$department_id][$slot_key])) {
                $counts[$department_id][$slot_key] = 0;
            }
            $counts[$department_id][$slot_key] += $wash_count;
        }

        return $counts;
    }

    /**
     * @throws Exception
     */
    private function countWashesForHour(array $department_ids, DateTime $hour_start): int
    {
        $start = clone $hour_start;
        $end = clone $hour_start;
        $end->add(new DateInterval('PT59M59S'));

        $orders = new orders_o();
        $total = 0;
        foreach ($department_ids as $department_id) {
            $total += $orders->countWashesInDateRange(
                $start->format('Y-m-d H:i:s'),
                $end->format('Y-m-d H:i:s'),
                (int)$department_id
            );
        }

        return $total;
    }

    private function mapWeatherCondition(int $code, string $text): string
    {
        $rain = [1063, 1150, 1153, 1180, 1183, 1186, 1189, 1192, 1195, 1240, 1243, 1246];
        $showers = [1072, 1168, 1171, 1198, 1201, 1249, 1252];
        $snow = [1066, 1069, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1255, 1258, 1261, 1264];
        $thunder = [1087, 1273, 1276, 1279, 1282];

        if ($code === 1000) {
            return 'clear';
        }
        if ($code === 1003) {
            return 'mostly_clear';
        }
        if ($code === 1006) {
            return 'partly_cloudy';
        }
        if ($code === 1009) {
            return 'mostly_cloudy';
        }
        if ($code === 1030 || str_contains(strtolower($text), 'overcast')) {
            return 'overcast';
        }
        if ($code === 1135 || $code === 1147) {
            return 'fog';
        }
        if (in_array($code, $thunder, true)) {
            return 'thunderstorm';
        }
        if (in_array($code, $snow, true)) {
            return 'snow';
        }
        if (in_array($code, $showers, true)) {
            return 'showers';
        }
        if (in_array($code, $rain, true)) {
            return 'rain';
        }

        return 'mostly_clear';
    }
}
