<?php
// prevent direct access

use classes\backup_store;
use classes\economic;
use classes\economic_transfer_queue;
use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\selfserve;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
use classes\system_search_registry;
use classes\workfeed;
use classes\workfeed_employee_name_formatter;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1;
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\selfserve_c;
use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
use objects\department_lanes_o;
use objects\department_goals_o;
use objects\department_selfserve_tasks_o;
use objects\departments_o;
use objects\bookings_o;
use objects\logs_o;
use objects\users_o;
use routes\moduleWeatherAPIRoute;

require_once __DIR__ . '/../classes/economic_transfer_executor.php';

const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
require_once __DIR__ . '/../classes/backup_store.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
require_once __DIR__ . '/../classes/cron_task_registry.php';
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
require_once __DIR__ . '/../classes/cron_scheduler.php';

if (!defined('WD')) {
    exit;
}

// Define the warning function
function warn($message): void
{
    echo "\n\033[33m$message\033[0m\n";
}

$response_cron = [];

// Define the cron tasks
$cron_tasks = [
//    'CheckUnfulfilledBookings' => [
//        'interval' => 86400, // 24 hours
//        'last_run' => 0,
//        'next_run' => 0,
//        'time' => '15:00',
//        'function' => 'checkUnfulfilledBookings',
//    ],
//    'SyncBookings' => [
//        'interval' => 60, // 1 minute
//        'last_run' => 0,
//        'next_run' => 0,
//        'function' => 'syncBookings',
//    ],
    'SyncLogs' => [
        'interval' => 300, // 5 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'syncLogsToDatabase',
    ],
    'ReplicaFailoverMonitorCron' => [
        'interval' => 60, // 1 minute
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'ReplicaFailoverMonitorCron',
    ],
    'CoolifyAvailabilityMonitorCron' => [
        'interval' => 60, // 1 minute
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'CoolifyAvailabilityMonitorCron',
    ],
    'CoolifyLoadBalancerReconcileCron' => [
        'interval' => 60, // 1 minute
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'CoolifyLoadBalancerReconcileCron',
    ],
    'SyncUserEconomicCustomerDiscounts' => [
        'interval' => 180, // 3 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SyncUserEconomicCustomerDiscounts',
    ],
    'SyncUserEconomicCustomerDetails' => [
        'interval' => 43200, // 12 hours
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SyncUserEconomicCustomerDetails',
    ],
    'SyncSystemSearchEconomicCustomerIndex' => [
        'interval' => 900, // 15 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SyncSystemSearchEconomicCustomerIndex',
    ],
    'backup' => [
        'interval' => 43200, // 12 hours
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'backup',
    ],
    'SyncEconomicInvoiceStatus' => [
        'interval' => 120, // 2 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SyncEconomicInvoiceStatus',
    ],
    'EconomicTransferQueueCron' => [
        'interval' => 30, // 30 seconds
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'EconomicTransferQueueCron',
    ],
    'SyncXLVaskModuleCron' => [
        'interval' => 3600, // 1 hour
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SyncXLVaskModuleCron',
    ],
    'SystemSearchCacheMaintenanceCron' => [
        'interval' => 300, // 5 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'SystemSearchCacheMaintenanceCron',
    ],
    'PreRenderDynamicImagesCron' => [
        'interval' => 900, // 15 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'PreRenderDynamicImagesCron',
    ],
    'PreloadDepartmentWeatherResponsesCron' => [
        'interval' => 60, // 1 minute
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'PreloadDepartmentWeatherResponsesCron',
    ],
    'WarmWorkfeedEmployeeNamesCron' => [
        'interval' => 21600, // 6 hours
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'WarmWorkfeedEmployeeNamesCron',
    ],
    'GoalsProgressAlertsCron' => [
        'interval' => 60, // check every minute
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'GoalsProgressAlertsCron',
    ],
    'PruneSystemSessionActivityCron' => [
        'interval' => 86400, // 24 hours
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'PruneSystemSessionActivityCron',
    ],
    'WarmInvoicePeriodManualFlagsCron' => [
        'interval' => 300, // 5 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'WarmInvoicePeriodManualFlagsCron',
    ],
    'WarmInvoicePeriodAutomaticFlagsCron' => [
        'interval' => 300, // 5 minutes
        'last_run' => 0,
        'next_run' => 0,
        'function' => 'WarmInvoicePeriodAutomaticFlagsCron',
    ],
];

function ReplicaFailoverMonitorCron(): void
{
    global $db;

    if (!($db instanceof \classes\db)) {
        warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.');
        return;
    }

    try {
        $result = (new replication_manager())->runAutomaticFailoverMonitor();
        $promoted = array_filter(
            $result['results'] ?? [],
            static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted'
        );
        echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: "
            . count($promoted) . " promotions.\n";
    } catch (Throwable $throwable) {
        warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage());
    }
}

function CoolifyAvailabilityMonitorCron(): void
{
    global $db;

    if (!($db instanceof \classes\db)) {
        warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.');
        return;
    }

    try {
        $result = (new coolify_manager())->runAvailabilityMaintenance();
        echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: "
            . count($result['targets'] ?? []) . " targets checked.\n";
    } catch (Throwable $throwable) {
        warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage());
    }
}

function CoolifyLoadBalancerReconcileCron(): void
{
    global $db;

    if (!($db instanceof \classes\db)) {
        warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.');
        return;
    }

    try {
        $manager = new coolify_manager();
        if (!$manager->loadBalancerAutomationEnabled()) {
            echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n";
            return;
        }

        $result = $manager->reconcileLoadBalancer(false);
        echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: "
            . count($result['applied'] ?? []) . " applied, "
            . count($result['skipped'] ?? []) . " skipped.\n";
    } catch (Throwable $throwable) {
        warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage());
    }
}

function WarmInvoicePeriodManualFlagsCron(): void
{
    (new invoice_period_flag_service())->warmManualFlagsCache();
}

function WarmInvoicePeriodAutomaticFlagsCron(): void
{
    $service = new invoice_period_flag_service();
    $now = new DateTime();
    $previousMonth = new DateTime('first day of previous month');

    $toWarm = [];
    foreach ([$now, $previousMonth] as $date) {
        $dateFrom = $date->format('Y-m-01');
        $dateTo = $date->format('Y-m-t');
        $toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo];
    }

    try {
        $queued = (new redis())->consume_invoice_period_warming_queue();
        foreach ($queued as $period) {
            $key = $period['dateFrom'] . '|' . $period['dateTo'];
            $toWarm[$key] = $period;
        }
    } catch (Throwable) {
    }

    foreach ($toWarm as $period) {
        $service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']);
        $service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']);
    }
}

function WarmWorkfeedEmployeeNamesCron(): void
{
    if (!defined('redis')) {
        warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.');
        return;
    }

    $start = microtime(true);

    $ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL');
    $ttl = max(
        60,
        (int)(
            $ttlRaw !== false && trim((string)$ttlRaw) !== ''
                ? $ttlRaw
                : 86400
        )
    );

    try {
        $employeesResponse = (new workfeed())->listEmployees();
    } catch (Throwable $e) {
        warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage());
        return;
    }

    $employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse);
    if ($employees === []) {
        echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n";
        return;
    }

    try {
        $cache = new redis();
    } catch (Throwable $e) {
        warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage());
        return;
    }

    $cachedCount = 0;
    $skippedCount = 0;
    foreach ($employees as $employee) {
        $identity = extractWorkfeedEmployeeWarmupIdentity($employee);
        $employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
        $employeeName = $identity['name'] ?? null;
        if ($employeeIds === [] || $employeeName === null) {
            $skippedCount++;
            continue;
        }

        foreach ($employeeIds as $employeeId) {
            $cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl);
            $cachedCount++;
        }
    }

    $durationMs = (int)round((microtime(true) - $start) * 1000);
    echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount
        . " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n";
}

/**
 * @return array<int,mixed>
 */
function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
{
    if (is_array($raw)) {
        return array_values($raw);
    }

    if ($raw instanceof Traversable) {
        return array_values(iterator_to_array($raw, false));
    }

    if (!is_object($raw)) {
        return [];
    }

    $record = get_object_vars($raw);
    foreach (['data', 'items', 'employees', 'results'] as $key) {
        $nested = $record[$key] ?? null;
        $normalized = normalizeWorkfeedEmployeeWarmupCollection($nested);
        if ($normalized !== []) {
            return $normalized;
        }
    }

    return [$raw];
}

/**
 * @return array{id: ?string, name: ?string}
 */
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
{
    $employeeIds = extractWorkfeedEmployeeWarmupIds($employee);

    $record = is_object($employee)
        ? get_object_vars($employee)
        : (is_array($employee) ? $employee : []);

    if ($record === []) {
        return [
            'id' => null,
            'name' => null,
        ];
    }

    $employeeId = $employeeIds[0] ?? null;

    $employeeName = workfeed_employee_name_formatter::fromRecord($record, [
        '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',
    ], $employeeId);

    return [
        'id' => $employeeId,
        'name' => $employeeName,
    ];
}

/**
 * @return array<int,string>
 */
function extractWorkfeedEmployeeWarmupIds(mixed $employee): array
{
    $record = is_object($employee)
        ? get_object_vars($employee)
        : (is_array($employee) ? $employee : []);

    if ($record === []) {
        return [];
    }

    $employeeIds = [];
    foreach ([
        'employeeID',
        'employeeId',
        'id',
        'uuid',
        'employee.id',
        'employee.employeeID',
        'employee.employeeId',
        'employee.uuid',
        'employeeUUID',
        'employee_uuid',
        'user.id',
        'userId',
    ] as $path) {
        $employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
        if ($employeeId === null) {
            continue;
        }

        $employeeIds[$employeeId] = true;
    }

    return array_keys($employeeIds);
}

function getWarmupRecordValueByPath(array $record, string $path): mixed
{
    $segments = explode('.', $path);
    $value = $record;
    foreach ($segments as $segment) {
        if (is_array($value) && array_key_exists($segment, $value)) {
            $value = $value[$segment];
            continue;
        }

        if (is_object($value) && isset($value->{$segment})) {
            $value = $value->{$segment};
            continue;
        }

        return null;
    }

    return $value;
}

function normalizeWarmupTextValue(mixed $value): ?string
{
    if (is_string($value) || is_numeric($value)) {
        $normalized = trim((string)$value);
        return $normalized !== '' ? $normalized : null;
    }

    return null;
}

function syncBookings(): void
{
    $bookings_o = new bookings_o();
    $response_cron[] = $bookings_o->syncBookings();
}

function syncLogsToDatabase(): void
{
    $logs_o = new logs_o();
    $logs_o->syncLogsToDatabase();
}

function backup(): void
{
    try {
        $backup = new backup_store();
        $backup->enqueueCreateBackup(null, null, null, 'scheduled');
    } catch (Exception $e) {
        warn('Backup failed: ' . $e->getMessage());
    }
}

function processBackupJobs(): array
{
    try {
        return (new backup_store())->processPendingJobs(3);
    } catch (Exception $e) {
        warn('Backup job processing failed: ' . $e->getMessage());
        return ['error' => $e->getMessage()];
    }
}

function pruneBackupRetention(): array
{
    try {
        $backup = new backup_store();
        $queued = $backup->enqueueRetentionPrune();
        return $backup->runJobById((int)$queued['job_id']);
    } catch (Exception $e) {
        warn('Backup retention prune failed: ' . $e->getMessage());
        return ['error' => $e->getMessage()];
    }
}

function SyncUserEconomicCustomerDiscounts(): void
{
    $users_o = new users_o();
    $users_o->clearAllUsersEconomicCustomerDiscountsFromCache();
}

function SyncUserEconomicCustomerDetails(): void
{
    try {
        $users_o = new users_o();
        $users_o->clearAllUsersEconomicCustomerDetailsFromCache();
        $users_o->syncAllUsersEconomicCustomerDetails();
        $stats = system_search_economic_customer_index::refreshIndex(false);
        system_search_document_index::refreshIndex([
            'customers',
            'customer_discounts',
            'customer_fixed_prices',
            'employees',
            'users',
        ]);
        system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE);
        echo "[" . date('Y-m-d H:i:s') . "][CRON] Refreshed e-conomic customer snapshots and search index. Upserted: "
            . (int)($stats['upserted'] ?? 0) . "\n";
    } catch (Throwable $e) {
        warn('SyncUserEconomicCustomerDetails failed: ' . $e->getMessage());
    }
}

function SyncSystemSearchEconomicCustomerIndex(): void
{
    try {
        $stats = system_search_economic_customer_index::refreshIndex(false);
        system_search_document_index::refreshIndex([
            'customers',
            'customer_discounts',
            'customer_fixed_prices',
            'employees',
            'users',
        ]);
        system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE);
        echo "[" . date('Y-m-d H:i:s') . "][CRON] Synced system search e-conomic customer index. Processed: "
            . (int)($stats['processed'] ?? 0) . ", upserted: " . (int)($stats['upserted'] ?? 0)
            . ", deleted: " . (int)($stats['deleted'] ?? 0) . "\n";
    } catch (Throwable $e) {
        warn('SyncSystemSearchEconomicCustomerIndex failed: ' . $e->getMessage());
    }
}

/**
 * @throws Exception
 */
function SyncEconomicInvoiceStatus(): void
{
    $economic = new economic();
    try {
        $economic->getTasks()->runCheckErrors();
    } catch (Exception $e) {
        // Do nothing, this is automatically running
    }
    try {
        $economic->getTasks()->runCheckDrafts();
    } catch (Exception $e) {
        // Do nothing, this is automatically running
    }
}

function SyncXLVaskModuleCron(): void
{
    $xlvask = new xlvask();
    try {
        if ($xlvask->config->enabled->isTrue()) {
            $xlvask->getTasks()->runCronTasks();
        }
    } catch (Exception $e) {
        // Do nothing, this is automatically running
    }
}

function EconomicTransferQueueCron(): void
{
    try {
        $queue = new economic_transfer_queue();
        $result = $queue->processPending(10);
        if ((int)($result['processed'] ?? 0) > 0) {
            echo "[" . date('Y-m-d H:i:s') . "][CRON] EconomicTransferQueueCron processed="
                . (int)($result['processed'] ?? 0)
                . " completed=" . (int)($result['completed'] ?? 0)
                . " failed=" . (int)($result['failed'] ?? 0) . "\n";
        }
    } catch (Throwable $e) {
        warn('EconomicTransferQueueCron failed: ' . $e->getMessage());
    }
}

function PruneSystemSessionActivityCron(): void
{
    try {
        $deleted = (new \classes\system_session_activity_tracker())->pruneOlderThanDays(30);
        if ($deleted > 0) {
            echo "[" . date('Y-m-d H:i:s') . "][CRON] Pruned $deleted stale system session activity rows\n";
        }
    } catch (Throwable $e) {
        warn('PruneSystemSessionActivityCron failed: ' . $e->getMessage());
    }
}

function SystemSearchCacheMaintenanceCron(): void
{
    try {
        $rebuildRequest = system_search_cache::consumeRebuildRequest();
        $dirtyTables = system_search_cache::consumeDirtyTables();

        if ($rebuildRequest !== null) {
            system_search_cache::clearQueryCaches();
            $scope = (string)($rebuildRequest['scope'] ?? 'all');
            $types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
            if ($scope === 'types' && !empty($types)) {
                system_search_document_index::refreshIndex($types);
            } else {
                system_search_economic_customer_index::refreshIndex(false);
                system_search_document_index::refreshIndex();
            }
            echo "[" . date('Y-m-d H:i:s') . "][CRON] System search cache rebuild handled. Scope: " . ($rebuildRequest['scope'] ?? 'all') . "\n";
            return;
        }

        if (!empty($dirtyTables)) {
            if (in_array('users', $dirtyTables, true)) {
                system_search_economic_customer_index::refreshIndex(false);
            }
            $typesToRefresh = system_search_registry::entityTypesForDirtyTables($dirtyTables);
            if (!empty($typesToRefresh)) {
                system_search_document_index::refreshIndex($typesToRefresh);
            }
            echo "[" . date('Y-m-d H:i:s') . "][CRON] System search maintenance handled dirty tables: " . implode(', ', $dirtyTables) . "\n";
        }
    } catch (Throwable $e) {
        warn('SystemSearchCacheMaintenanceCron failed: ' . $e->getMessage());
    }
}

function PreRenderDynamicImagesCron(): void
{
    $start = microtime(true);
    $cacheTtlSeconds = 86400;
    $maxRendersPerRun = 500;

    if (!defined('redis')) {
        warn('PreRenderDynamicImagesCron skipped: Redis is unavailable.');
        return;
    }

    if (!extension_loaded('imagick')) {
        warn('PreRenderDynamicImagesCron skipped: Imagick extension is not loaded.');
        return;
    }

    try {
        $laneRows = (new department_lanes_o())->getFieldsWhere([
            'deleted_at' => null,
            'dynamic_image_id' => '!null',
        ], [
            'id',
            'department',
            'dynamic_image_id',
            'machine_type_id',
        ]);
    } catch (Throwable $e) {
        warn('PreRenderDynamicImagesCron failed to read lanes: ' . $e->getMessage());
        return;
    }

    if (!is_array($laneRows) || count($laneRows) === 0) {
        echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron: no dynamic-image lanes found.\n";
        return;
    }

    $variantsByCacheKey = [];
    $unsupportedImageIds = [];

    foreach ($laneRows as $laneRow) {
        $dynamicImageId = (int)($laneRow['dynamic_image_id'] ?? 0);
        if ($dynamicImageId <= 0) {
            continue;
        }
        if ($dynamicImageId !== 1) {
            $unsupportedImageIds[$dynamicImageId] = true;
            continue;
        }

        $taskGroups = collectDynamicImageTaskGroupsForLane($laneRow);
        if ($taskGroups === []) {
            $taskGroups = [[
                'vehicle_type' => null,
                'rows' => [],
            ]];
        }

        foreach ($taskGroups as $group) {
            $groupVariants = buildDynamicImageVariantsForTaskGroup(
                $dynamicImageId,
                normalizeDynamicImageVehicleType($group['vehicle_type'] ?? null),
                is_array($group['rows'] ?? null) ? $group['rows'] : []
            );

            foreach ($groupVariants as $variant) {
                $cacheKey = buildDynamicImageCacheKey($variant);
                if ($cacheKey === '') {
                    continue;
                }
                $variantsByCacheKey[$cacheKey] = $variant + ['cache_key' => $cacheKey];
            }
        }
    }

    $discovered = count($variantsByCacheKey);
    $rendered = 0;
    $alreadyCached = 0;
    $failed = 0;
    $skippedByCap = 0;
    $attempted = 0;

    foreach ($variantsByCacheKey as $cacheKey => $variant) {
        if (redis->exists($cacheKey)) {
            $alreadyCached++;
            continue;
        }

        if ($attempted >= $maxRendersPerRun) {
            $skippedByCap++;
            continue;
        }
        $attempted++;

        $imageData = renderDynamicImageVariant(
            (int)$variant['dynamic_image_id'],
            $variant['buttons'],
            (int)$variant['current_step'],
            (bool)$variant['only_current_step']
        );

        if ($imageData === null) {
            $failed++;
            continue;
        }

        redis->setEx($cacheKey, $imageData, $cacheTtlSeconds);
        $rendered++;
    }

    $duration = round(microtime(true) - $start, 2);
    $unsupportedList = empty($unsupportedImageIds) ? 'none' : implode(', ', array_keys($unsupportedImageIds));
    echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron completed. "
        . "discovered=$discovered rendered=$rendered cached=$alreadyCached failed=$failed "
        . "skipped_by_cap=$skippedByCap unsupported_image_ids=$unsupportedList duration={$duration}s\n";
}

/**
 * @return array<int,array{vehicle_type:int|null,rows:array<int,array<string,mixed>>}>
 */
function collectDynamicImageTaskGroupsForLane(array $laneRow): array
{
    $laneId = (int)($laneRow['id'] ?? 0);
    $departmentId = (int)($laneRow['department'] ?? 0);
    $machineTypeId = normalizeDynamicImageVehicleType($laneRow['machine_type_id'] ?? null);
    $tasksObject = new department_selfserve_tasks_o();

    $rows = [];
    if ($machineTypeId !== null) {
        try {
            $rows = $tasksObject->getTasksForMachineType($machineTypeId);
        } catch (Throwable $e) {
            warn('PreRenderDynamicImagesCron: failed reading machine-type tasks for lane #' . $laneId . ': ' . $e->getMessage());
            $rows = [];
        }
    }

    if (!is_array($rows) || $rows === []) {
        try {
            $rows = $tasksObject->getFieldsWhere([
                'department' => $departmentId,
                'lane' => $laneId,
                'deleted_at' => null,
            ], [
                'id',
                'product',
                'order_priority',
                'buttons',
                'dynamic_images_vehicle_type',
            ]);
        } catch (Throwable $e) {
            warn('PreRenderDynamicImagesCron: failed reading legacy tasks for lane #' . $laneId . ': ' . $e->getMessage());
            $rows = [];
        }
    }

    if (!is_array($rows) || $rows === []) {
        return [];
    }

    $grouped = [];
    foreach ($rows as $row) {
        $vehicleType = normalizeDynamicImageVehicleType($row['dynamic_images_vehicle_type'] ?? null);
        if ($vehicleType === null) {
            $productVehicleType = normalizeDynamicImageVehicleType($row['product'] ?? null);
            if ($productVehicleType !== null && $productVehicleType > 0) {
                $vehicleType = $productVehicleType;
            }
        }
        $groupKey = $vehicleType === null ? 'null' : 'v' . $vehicleType;
        if (!isset($grouped[$groupKey])) {
            $grouped[$groupKey] = [
                'vehicle_type' => $vehicleType,
                'rows' => [],
            ];
        }
        $grouped[$groupKey]['rows'][] = $row;
    }

    return array_values($grouped);
}

/**
 * @param array<int,array<string,mixed>> $taskRows
 * @return array<int,array{dynamic_image_id:int,buttons:array<int|string>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
 */
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
{
    $variants = [];

    usort($taskRows, static function (array $a, array $b): int {
        $priorityA = (int)($a['order_priority'] ?? 0);
        $priorityB = (int)($b['order_priority'] ?? 0);
        if ($priorityA !== $priorityB) {
            return $priorityA <=> $priorityB;
        }
        return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0));
    });

    $buttonSets = [null];
    $runningButtons = [];

    foreach ($taskRows as $row) {
        $buttons = parseDynamicImageButtons($row['buttons'] ?? null);
        if ($buttons !== []) {
            $buttonSets[] = $buttons;
            $runningButtons = mergeUniqueButtonValues($runningButtons, $buttons);
            $buttonSets[] = $runningButtons;
        }
    }

    $dedupedButtonSets = [];
    foreach ($buttonSets as $buttonSet) {
        $signature = $buttonSet === null ? 'null' : json_encode(array_values($buttonSet));
        if ($signature === false || isset($dedupedButtonSets[$signature])) {
            continue;
        }
        $dedupedButtonSets[$signature] = $buttonSet;
    }

    foreach ($dedupedButtonSets as $buttons) {
        $buttonCount = is_array($buttons) ? count($buttons) : 0;
        $maxStep = min(max(0, $buttonCount + 3), 15);
        for ($currentStep = 0; $currentStep <= $maxStep; $currentStep++) {
            $variants[] = [
                'dynamic_image_id' => $dynamicImageId,
                'buttons' => $buttons,
                'current_step' => $currentStep,
                'only_current_step' => false,
                'vehicle_type' => $vehicleType,
            ];
            $variants[] = [
                'dynamic_image_id' => $dynamicImageId,
                'buttons' => $buttons,
                'current_step' => $currentStep,
                'only_current_step' => true,
                'vehicle_type' => $vehicleType,
            ];
        }
    }

    return $variants;
}

/**
 * @param array<int|string>|null $buttons
 */
function buildDynamicImageCacheKey(array $variant): string
{
    $cacheParams = [
        'dynamic_image_id' => (int)($variant['dynamic_image_id'] ?? 0),
        'buttons' => $variant['buttons'] ?? null,
        'current_step' => (int)($variant['current_step'] ?? 0),
        'only_current_step' => (bool)($variant['only_current_step'] ?? false),
        'vehicle_type' => $variant['vehicle_type'] ?? null,
        'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
    ];

    $json = json_encode($cacheParams);
    if ($json === false) {
        return '';
    }

    return 'dynamic_image:' . md5($json);
}

/**
 * @param array<int|string>|null $buttons
 */
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
{
    $image = null;
    try {
        switch ($dynamicImageId) {
            case 1:
                $image = new machine_1();
                break;
            default:
                warn('PreRenderDynamicImagesCron: unsupported dynamic_image_id=' . $dynamicImageId);
                return null;
        }

        if (is_array($buttons)) {
            $image->highlighted_buttons = $buttons;
        }
        $image->current_step = max(0, $currentStep);
        $image->only_generate_current_step = $onlyCurrentStep;
        $image->setup();
        if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
            $image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
        }

        return $image->exportBinary('png');
    } catch (Throwable $e) {
        warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
        return null;
    } finally {
        if (is_object($image) && method_exists($image, 'clearImage')) {
            try {
                $image->clearImage();
            } catch (Throwable) {
            }
        }
    }
}

function getSelfServeDynamicImageSizeModeForCron(): string
{
    try {
        $mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
    } catch (Throwable) {
        return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
    }

    return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
        ? $mode
        : selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
}

/**
 * @param mixed $value
 * @return array<int|string>
 */
function parseDynamicImageButtons(mixed $value): array
{
    if ($value === null || $value === '') {
        return [];
    }

    try {
        return department_selfserve_tasks_o::normalizeButtonsInput($value);
    } catch (Throwable) {
        return [];
    }
}

/**
 * @param mixed $value
 */
function normalizeDynamicImageVehicleType(mixed $value): ?int
{
    if ($value === null) {
        return null;
    }
    if (is_string($value)) {
        $value = trim($value);
        if ($value === '' || strtolower($value) === 'null') {
            return null;
        }
    }
    if (!is_numeric($value)) {
        return null;
    }
    $normalized = (int)$value;
    if ($normalized < 0) {
        return null;
    }
    return $normalized;
}

/**
 * @param array<int|string> $base
 * @param array<int|string> $append
 * @return array<int|string>
 */
function mergeUniqueButtonValues(array $base, array $append): array
{
    $result = $base;
    $seen = [];
    foreach ($result as $value) {
        $seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true;
    }
    foreach ($append as $value) {
        $key = (is_int($value) ? 'int:' : 'string:') . (string)$value;
        if (!isset($seen[$key])) {
            $seen[$key] = true;
            $result[] = $value;
        }
    }
    return array_values($result);
}

function PreloadDepartmentWeatherResponsesCron(): void
{
    $start = microtime(true);

    if (!defined('redis')) {
        warn('PreloadDepartmentWeatherResponsesCron skipped: Redis is unavailable.');
        return;
    }

    $maxDepartmentsRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS');
    $maxDepartmentsPerRun = max(
        1,
        (int)(
            $maxDepartmentsRaw !== false && trim((string)$maxDepartmentsRaw) !== ''
                ? $maxDepartmentsRaw
                : 25
        )
    );
    $hotLimitRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT');
    $hotLimit = max(
        1,
        (int)(
            $hotLimitRaw !== false && trim((string)$hotLimitRaw) !== ''
                ? $hotLimitRaw
                : 50
        )
    );
    $hotTtlRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
    $hotTtl = max(
        1,
        (int)(
            $hotTtlRaw !== false && trim((string)$hotTtlRaw) !== ''
                ? $hotTtlRaw
                : 900
        )
    );

    try {
        $departmentRows = (new departments_o())->list(true);
    } catch (Throwable $e) {
        warn('PreloadDepartmentWeatherResponsesCron failed to list departments: ' . $e->getMessage());
        return;
    }

    if (!is_array($departmentRows) || $departmentRows === []) {
        echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no departments found.\n";
        return;
    }

    usort($departmentRows, static function (array $left, array $right): int {
        $leftPriority = isset($left['order_priority']) ? (int)$left['order_priority'] : PHP_INT_MAX;
        $rightPriority = isset($right['order_priority']) ? (int)$right['order_priority'] : PHP_INT_MAX;
        if ($leftPriority !== $rightPriority) {
            return $leftPriority <=> $rightPriority;
        }

        return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0));
    });

    $departmentIds = [];
    foreach ($departmentRows as $row) {
        $departmentId = (int)($row['id'] ?? 0);
        if ($departmentId < 1) {
            continue;
        }
        if (isset($row['visible']) && (int)$row['visible'] === 0) {
            continue;
        }

        $departmentIds[$departmentId] = true;
        if (count($departmentIds) >= $maxDepartmentsPerRun) {
            break;
        }
    }

    // Fallback: if visibility is unavailable or all are hidden, include all departments up to cap.
    if ($departmentIds === []) {
        foreach ($departmentRows as $row) {
            $departmentId = (int)($row['id'] ?? 0);
            if ($departmentId < 1) {
                continue;
            }
            $departmentIds[$departmentId] = true;
            if (count($departmentIds) >= $maxDepartmentsPerRun) {
                break;
            }
        }
    }

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

    if ($departmentIds === []) {
        echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no preloadable departments.\n";
        return;
    }

    $warmedSets = 0;
    $failedSets = 0;
    $totalEntries = 0;
    $hotTargetsRequested = 0;
    $hotTargetsWarmed = 0;

    $hotTargets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets($hotLimit, $hotTtl);
    $hotTargetsRequested = count($hotTargets);
    foreach ($hotTargets as $target) {
        try {
            $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache(
                $target['department_ids'] ?? [],
                null,
                null,
                $target['timeline_range'] ?? null
            );
            if (($result['warmed'] ?? false) === true) {
                $warmedSets++;
                $hotTargetsWarmed++;
                $totalEntries += (int)($result['entries'] ?? 0);
            }
        } catch (Throwable $e) {
            $failedSets++;
            warn('PreloadDepartmentWeatherResponsesCron failed for hot target: ' . $e->getMessage());
        }
    }

    foreach ($departmentIds as $departmentId) {
        try {
            $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache([$departmentId]);
            if (($result['warmed'] ?? false) === true) {
                $warmedSets++;
                $totalEntries += (int)($result['entries'] ?? 0);
            }
        } catch (Throwable $e) {
            $failedSets++;
            warn('PreloadDepartmentWeatherResponsesCron failed for department #' . $departmentId . ': ' . $e->getMessage());
        }
    }

    if (count($departmentIds) > 1) {
        try {
            $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache($departmentIds);
            if (($result['warmed'] ?? false) === true) {
                $warmedSets++;
                $totalEntries += (int)($result['entries'] ?? 0);
            }
        } catch (Throwable $e) {
            $failedSets++;
            warn('PreloadDepartmentWeatherResponsesCron failed for aggregate department set: ' . $e->getMessage());
        }
    }

    $duration = round(microtime(true) - $start, 2);
    echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron completed. "
        . "departments=" . count($departmentIds)
        . " warmed_sets=$warmedSets failed_sets=$failedSets total_entries=$totalEntries "
        . "hot_targets_requested=$hotTargetsRequested hot_targets_warmed=$hotTargetsWarmed "
        . "max_departments_per_run=$maxDepartmentsPerRun hot_limit=$hotLimit hot_ttl=$hotTtl "
        . "duration={$duration}s\n";
}

/**
 * GoalsProgressAlertsCron
 *
 * Iterates all active department goals and, based on each goal's criteria, sends progress alerts
 * according to frequency, selected weekdays, and an optional time-of-day with timezone.
 *
 * Scheduling rules (defaults when not fully specified):
 * - Frequency NONE: skip.
 * - DAILY: send once per calendar day at the configured time (or at :00 of the current minute if no time provided).
 * - WEEKLY: send once per selected weekday(s) at the configured time (if none selected, Monday).
 * - MONTHLY: send once on the 1st of each month at the configured time (00:00 if not provided).
 * - CHANGED: send when the computed progress value changes since the last check (debounced to 1 hour).
 *
 * Destinations:
 * - SLACK: send to each department's configured webhook; if none, fallback to default Slack webhook.
 * - EMAIL/SMS: currently require explicit recipients in manual route; automatic cron will skip and log a warning.
 * @throws Exception
 */
function GoalsProgressAlertsCron(): void
{
    // Run department overview goals mondays
    if ((int)date('N') === 1) {
        // Check if the time is between 07:00 and 17:00
        if (!redis->exists('system:last_sent_monday_message_v2') && (date('H') >= 7 && date('H') < 17)) {
            // Set the key to expire in 24 hours
            redis->set('system:last_sent_monday_message_v2', (string)time());
            redis->expire('system:last_sent_monday_message_v2', 86400);
            $department = new departments_o();
            $days = 7;
            // The time should be from 00:00:00 of the start date to 23:59:59 of the end date
            $date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59
            $date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00
            // Send the messages
            $departments = [
                 1,
                 2,
                 3,
                 4,
                 5,
                 6,
                 7,
                 13,
                 14,
                 84,
             ];
            $department->sendSlackInternalStatisticNotification($date_start, $date_end, $departments);
        }
    }
    // List all goals (not deleted)
    $goals = new department_goals_o();
    $rows = $goals->listObjects(function ($row) {
        return (int)$row['id'];
    });

    if (!is_array($rows) || count($rows) === 0) {
        return;
    }

    $nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));

    foreach ($rows as $goalId) {
        try {
            $goal = (new department_goals_o())->select($goalId);
            if (!$goal->exists()) { continue; }
            // Skip soft-deleted
            if (method_exists($goal, 'deleted_at') && !empty((string)$goal->deleted_at->value())) { continue; }

            $criteriaArray = (array)$goal->criteria->value();
            // Build criteria object
            $criteria = goals_criteria::fromJson(json_encode($criteriaArray));
            $criteria->validateAndSanitize();

            $frequency = $criteria->progress_alert_frequency ?? Freq::NONE;
            if ($frequency === Freq::NONE) { continue; }

            // Check if due
            $dueInfo = goalsProgressAlertDue($criteria, $nowUtc);

            // Special handling for CHANGED: only proceed if progress value changed since last send
            if (($frequency === Freq::CHANGED)) {
                $currentProgress = goals_criteria::calculateProgressFromArray($criteriaArray);
                $lastProgressKey = 'goal_alert_last_progress:' . $goalId;
                $lastProgress = redis->get($lastProgressKey) ?? null;
                if ($lastProgress !== null && (string)$lastProgress === (string)$currentProgress) {
                    continue; // no change
                }
                // For CHANGED, dedupe by progress value so each distinct value sends once
                $dueInfo['slot'] = 'changed-' . md5((string)$currentProgress);
                $dueInfo['ttl'] = 86400 * 30; // keep dedupe for a while
            } else {
                if (!$dueInfo['due']) { continue; }
            }

            // Deduplicate per goal per slot via Redis using an atomic reservation.
            // This prevents two concurrent cron runners from both sending the same alert.
            $slotKey = $dueInfo['slot'];
            $redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey;
            $ttl = max(1, (int)$dueInfo['ttl']);
            if (!redis->set_if_absent_with_expiration($redisKey, '1', $ttl)) {
                continue;
            }

            // Render message
            $message = goals_progress_alert_renderer::render($criteria);

            // Dispatch according to destination
            $destination = $criteria->progress_alert_destination ?? Dest::SLACK;
            $sent = false;
            try {
                switch ($destination) {
                    case Dest::SLACK:
                        $departments = (array)$goal->departments->value();
                        $sentToDept = false;
                        if (count($departments) > 0) {
                            foreach ($departments as $deptId) {
                                if (!is_numeric($deptId)) { continue; }
                                $dept = (new departments_o())->select((int)$deptId);
                                if (!$dept->exists()) { continue; }
                                $webhook = (string)$dept->slack_webhook->value();
                                if (empty($webhook)) { continue; }
                                (new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook);
                                $sentToDept = true;
                            }
                        }
                        if (!$sentToDept) {
                            // Fallback to default webhook
                            (new Slack())->send_message($message);
                        }
                        $sent = true;
                        break;
                    case Dest::EMAIL:
                        // No recipient context in goal for automated cron
                        warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId);
                        break;
                    case Dest::SMS:
                        // No recipient context in goal for automated cron
                        warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId);
                        break;
                    default:
                        // Unsupported or NONE
                        warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId);
                }
            } catch (Throwable $dispatchError) {
                if (!$sent) {
                    redis->delete($redisKey);
                }
                throw $dispatchError;
            }

            // Track last progress for CHANGED
            if ($frequency === Freq::CHANGED) {
                $progressVal = goals_criteria::calculateProgressFromArray($criteriaArray);
                redis->set('goal_alert_last_progress:' . $goalId, (string)$progressVal);
                if (method_exists(redis, 'expire')) {
                    redis->expire('goal_alert_last_progress:' . $goalId, 86400 * 30);
                }
            }
        } catch (Throwable $e) {
            warn('GoalsProgressAlertsCron error for goal #' . $goalId . ': ' . $e->getMessage());
        }
    }
}

/**
 * Decide if an alert is due for the given criteria at the provided UTC time.
 * Returns ['due' => bool, 'slot' => string, 'ttl' => int]
 */
function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowUtc): array
{
    $frequency = $criteria->progress_alert_frequency ?? Freq::NONE;

    // Determine alert time in a timezone (defaults to UTC current minute)
    $timeStr = $criteria->progress_alert_time_of_day;
    $alertMinuteUtc = null; // DateTimeImmutable normalized to the scheduled minute in UTC
    $slot = '';
    $ttl = 3600; // default TTL window for deduplication

    $weekdays = $criteria->progress_alert_weekdays ?? [];
    $weekdayNames = array_map(fn($e) => ($e instanceof UnitEnum ? $e->name : (string)$e), $weekdays);

    // Helper to build DateTime at today with provided HH:MM in given offset
    $buildTodayAt = function (string $hhmmTz) use ($nowUtc): ?DateTimeImmutable {
        // Convert "HH:MMZ" or with offset to a concrete time today
        if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)(Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/', $hhmmTz)) {
            return null;
        }
        // Extract parts
        [$h, $m, $tz] = [substr($hhmmTz,0,2), substr($hhmmTz,3,2), substr($hhmmTz,5)];
        $tzStr = $tz;
        if ($tzStr === 'Z') { $tzStr = '+00:00'; }
        if (preg_match('/^[+-]\d{4}$/', $tzStr)) {
            // Normalize +HHMM
            $tzStr = substr($tzStr,0,3) . ':' . substr($tzStr,3,2);
        }
        $localTz = new DateTimeZone($tzStr);
        $todayLocal = new DateTimeImmutable('now', $localTz);
        $scheduledLocal = $todayLocal->setTime((int)$h, (int)$m, 0, 0);
        // Convert to UTC
        return $scheduledLocal->setTimezone(new DateTimeZone('UTC'));
    };

    // Build scheduled time today (or fallback to current minute)
    if (is_string($timeStr) && $timeStr !== '') {
        $alertMinuteUtc = $buildTodayAt($timeStr);
    }
    if (!$alertMinuteUtc) {
        // Fallback: use current minute
        $alertMinuteUtc = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0);
    }

    $nowMinute = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0);

    switch ($frequency) {
        case Freq::DAILY:
            $slot = $nowUtc->format('Y-m-d');
            $ttl = 86400; // 1 day
            // If weekdays specified, restrict to those
            if (!empty($weekdayNames)) {
                $phpWeekday = strtoupper($nowUtc->format('l'));
                if (!in_array($phpWeekday, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; }
            }
            return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
        case Freq::WEEKLY:
            $slot = $nowUtc->format('o-W') . (empty($weekdayNames) ? '-MONDAY' : '-' . strtoupper($nowUtc->format('l')));
            $ttl = 86400 * 7;
            // Default to Monday if none specified
            if (empty($weekdayNames)) {
                $isMonday = ($nowUtc->format('N') === '1');
                return ['due' => $isMonday && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
            }
            $todayName = strtoupper($nowUtc->format('l'));
            if (!in_array($todayName, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; }
            return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
        case Freq::MONTHLY:
            $slot = $nowUtc->format('Y-m');
            $ttl = 86400 * 31;
            $isFirstOfMonth = ($nowUtc->format('j') === '1');
            return ['due' => $isFirstOfMonth && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
        case Freq::CHANGED:
            // Debounce to 1 hour per change
            $ttl = 3600;
            $slot = $nowUtc->format('Y-m-d-H');
            $lastProgress = redis->get('goal_alert_last_progress:' . ($criteria->label ?? '')) ?? null; // We'll override with goal id upstream
            // Always mark due here; upstream dedupe per goal id + hour slot applies, but we also check change upstream
            return ['due' => true, 'slot' => $slot, 'ttl' => $ttl];
        default:
            return ['due' => false, 'slot' => '', 'ttl' => 3600];
    }
}

function SelfserveOpeningRelayActivationCron(): array
{
    global $db;

    if (!($db instanceof \classes\db)) {
        warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
        return [
            'skipped' => true,
            'reason' => 'database_unavailable',
        ];
    }

    $now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
    $candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
    $summary = [
        'checked_departments' => count($candidates),
        'activated_departments' => 0,
        'skipped_departments' => 0,
        'failed_departments' => 0,
        'activated_relays' => 0,
        'skipped_relays' => 0,
        'failed_relays' => 0,
    ];

    foreach ($candidates as $candidate) {
        $departmentId = (int)($candidate['department_id'] ?? 0);
        $opensAt = (string)($candidate['opens_at'] ?? '');
        if ($departmentId <= 0 || $opensAt === '') {
            $summary['skipped_departments']++;
            continue;
        }

        $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
        if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
            $summary['skipped_departments']++;
            continue;
        }

        $departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
        $summary['activated_relays'] += (int)$departmentSummary['activated'];
        $summary['skipped_relays'] += (int)$departmentSummary['skipped'];
        $summary['failed_relays'] += (int)$departmentSummary['failed'];

        if ((int)$departmentSummary['failed'] > 0) {
            $summary['failed_departments']++;
            continue;
        }

        selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
        if ((int)$departmentSummary['activated'] > 0) {
            $summary['activated_departments']++;
        } else {
            $summary['skipped_departments']++;
        }
    }

    echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
        . $summary['activated_relays'] . " cleaner relays activated across "
        . $summary['activated_departments'] . " departments.\n";

    return $summary;
}

function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
{
    global $db;

    $weekday = strtolower($now->format('l'));
    $allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
    if (!in_array($weekday, $allowedWeekdays, true)) {
        return [];
    }

    $startColumn = $weekday . '_start';
    $endColumn = $weekday . '_end';
    $sql = "
        SELECT
            oh.department AS department_id,
            TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
            TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
        FROM department_time_bookings_opening_hours oh
        INNER JOIN department_variables dv ON dv.department_id = oh.department
        INNER JOIN department_lanes dl ON dl.department = oh.department
        WHERE dv.variable = 'selfserve_enabled'
          AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
          AND oh.`$startColumn` IS NOT NULL
          AND oh.`$endColumn` IS NOT NULL
          AND dl.deleted_at IS NULL
          AND COALESCE(dl.selfserve_enabled, 1) = 1
          AND dl.relay_machine_cleaner_id IS NOT NULL
          AND TRIM(dl.relay_machine_cleaner_id) <> ''
        GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
    ";

    $result = $db->query($sql);
    if (!$result instanceof mysqli_result) {
        return [];
    }

    return array_values(array_filter(
        $db->fetch_all($result),
        static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
            $now,
            (string)($row['opens_at'] ?? ''),
            (string)($row['closes_at'] ?? '')
        )
    ));
}

function selfserveOpeningCleanerRelayWindowActive(
    DateTimeImmutable $now,
    ?string $opensAt,
    ?string $closesAt
): bool {
    $opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
    $closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
    if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
        return false;
    }

    $nowSeconds = ((int)$now->format('G') * 3600)
        + ((int)$now->format('i') * 60)
        + (int)$now->format('s');

    if ($opensAtSeconds < $closesAtSeconds) {
        return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
    }

    return $nowSeconds >= $opensAtSeconds;
}

function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{
    $time = trim((string)$time);
    if ($time === '') {
        return null;
    }

    if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
        return null;
    }

    $hours = (int)$matches[1];
    $minutes = (int)$matches[2];
    $seconds = isset($matches[3]) ? (int)$matches[3] : 0;

    if ($hours > 23 || $minutes > 59 || $seconds > 59) {
        return null;
    }

    return ($hours * 3600) + ($minutes * 60) + $seconds;
}

function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
{
    $summary = [
        'activated' => 0,
        'skipped' => 0,
        'failed' => 0,
    ];
    $selfserve = new selfserve();
    $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);

    foreach ($lanes as $departmentLane) {
        if (!($departmentLane instanceof department_lanes_o)) {
            $summary['skipped']++;
            continue;
        }

        try {
            if (!$departmentLane->isSelfServeEnabled()) {
                $summary['skipped']++;
                continue;
            }
        } catch (Throwable) {
            $summary['skipped']++;
            continue;
        }

        if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
            $summary['skipped']++;
            continue;
        }

        $laneId = (int)$departmentLane->id;
        if ($laneId <= 0) {
            $summary['skipped']++;
            continue;
        }

        try {
            $lane = $selfserve->lane($laneId);
            if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
                $summary['skipped']++;
                continue;
            }

            $lane->setMachineCleanerRelayStatusHard(true);
            $summary['activated']++;
        } catch (Throwable $throwable) {
            $summary['failed']++;
            warn(
                'SelfserveOpeningRelayActivationCron failed for department '
                . $departmentId
                . ', lane '
                . $laneId
                . ': '
                . $throwable->getMessage()
            );
        }
    }

    return $summary;
}

function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
    if (
        $departmentLane === null
        || !isset($departmentLane->relay_machine_cleaner_id)
        || !is_object($departmentLane->relay_machine_cleaner_id)
        || !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
    ) {
        return false;
    }

    try {
        $relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
    } catch (Throwable) {
        return false;
    }

    return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
}

function selfserveOpeningCleanerRelayActivationKey(
    int $departmentId,
    string $opensAt,
    DateTimeImmutable $now
): string {
    $normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
    return 'selfserve:opening-cleaner-relays:'
        . $now->format('Y-m-d')
        . ':'
        . $departmentId
        . ':'
        . $normalizedOpensAt;
}

function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
{
    if (!defined('redis')) {
        return false;
    }

    try {
        return redis->exists($cacheKey);
    } catch (Throwable) {
        return false;
    }
}

function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
{
    if (!defined('redis')) {
        return;
    }

    try {
        redis->setEx($cacheKey, '1', 36 * 3600);
    } catch (Throwable) {
        // Redis idempotency should not block relay activation.
    }
}

if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
    return;
}

try {
    $response_cron = (new \classes\cron_scheduler())->runDue('automatic');
} catch (Throwable $throwable) {
    warn('Cron scheduler failed: ' . $throwable->getMessage());
    $response_cron = [
        'ran' => [],
        'count' => 0,
        'error' => $throwable->getMessage(),
    ];
}
