<?php

namespace routes;

use classes\authentication;
use classes\edge_gateway_manager;
use classes\email;
use classes\response;
use classes\router;
use classes\selfserve;
use classes\stripe;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_state;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\helpers\selfserve_wash_session_status;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\department_lanes_o;
use objects\departments_o;
use objects\edge_gateway_relay_bindings_o;
use objects\logs_o;
use objects\orders_o;
use objects\customer_vehicles_o;
use objects\selfserve_wash_sessions_o;
use objects\selfserve_wash_session_tasks_o;
use objects\stripe_module_customers_o;
use objects\subusers_o;
use objects\users_o;
use traits\route_t;

class moduleSelfServeRoute
{
    use route_t;

    private const MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5;
    private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
    private const CUSTOMER_SELFSERVE_USE_PERMISSION = 'add_own_department_selfserve_vehicle_conditions';

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


        /** Modules > Self Serve > Lane > Status */
        $this->get('/modules/self-serve/lane/status', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_status_view');
            $selfserve = new selfserve();
            $lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : 1;
            $lane = $selfserve->lane($lane_id);
            $response->success([
                'id' => $lane->id,
                'status' => $lane->getLaneStatus()->name,
                'mode' => $lane->getLaneMode()->name,
                'state' => $lane->getLaneState()->name,
                'wash_start_time' => $lane->getWashStartTime(),
                'elapsed_wash_time' => $lane->getElapsedWashTime(),
                'license_plate' => $lane->getLicensePlate(),
                'customer_number' => $lane->getCustomerNumber(),
            ]);
        },
            [
                'modules_selfserve_lane_status_view' => 'View self-serve lane status',
            ]
        );

        $this->put('/modules/self-serve/lane/status', function () {
            global $response;

            self::requirePermission('modules_selfserve_lane_status_set');
            self::requireParameters(['lane_id', 'enabled']);
            $lane_id = (int)self::getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $department_lane = (new department_lanes_o())->select($lane_id);
            if (!$department_lane->exists()) {
                $response->error('Department lane not found', 404);
            }
            self::requireDepartmentAccess((int)$department_lane->department->value());

            $enabled = $this->requestedBoolean('enabled');
            $target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
            $lane = (new selfserve())->lane($lane_id);
            $lane->setLaneStatus($target_status);

            $user = (new authentication())->get_user();
            $machine_status_audit = [
                'modified_at' => date(DATE_ATOM),
                'modified_by_user_id' => $user ? (int)$user->id : null,
                'modified_by_name' => $this->machineStatusAuditUserName($user),
            ];
            $lane->setLaneStatusAudit($machine_status_audit);

            (new logs_o())->add(
                'selfserve',
                'global',
                1,
                $user ? $user->id : 0,
                'SET_LANE_MACHINE_STATUS',
                'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
            );

            $status = (string)$lane->getLaneStatus()->name;
            $response->success([
                'id' => $lane->id,
                'status' => $status,
                'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
                'machine_status_audit' => $machine_status_audit,
                'machine_status_modified_at' => $machine_status_audit['modified_at'],
                'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
                'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
                'lane' => $department_lane->asArray(),
            ]);
        },
            [
                'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
            ]
        );

        /** Modules > Self Serve > Lane > Wash > In-progress details */
        $this->get('/modules/self-serve/lane/wash/in-progress', function () {
            global $response;
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $customer_scope = $this->requireInProgressWashDetailsAccess($lane_id);

            $build_customer = static function (?int $customer_number): ?array {
                if ($customer_number === null || $customer_number <= 0) {
                    return null;
                }

                $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number);
                if ($customer_obj->exists()) {
                    return [
                        'id' => (int)$customer_obj->id,
                        'customer_number' => $customer_number,
                        'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(),
                        'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(),
                        'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(),
                        'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(),
                    ];
                }

                return [
                    'id' => null,
                    'customer_number' => $customer_number,
                    'display_name' => null,
                    'email' => null,
                    'phone_country_code' => null,
                    'phone' => null,
                ];
            };

            $build_vehicle = static function (?int $vehicle_id, ?string $reg): ?array {
                $vehicle_obj = null;
                if ($vehicle_id !== null && $vehicle_id > 0) {
                    $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id);
                    if ($tmp_vehicle->exists()) {
                        $vehicle_obj = $tmp_vehicle;
                    }
                }
                if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') {
                    $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg));
                    if ($tmp_vehicle->exists()) {
                        $vehicle_obj = $tmp_vehicle;
                    }
                }
                if ($vehicle_obj === null) {
                    return null;
                }

                return [
                    'id' => (int)$vehicle_obj->id,
                    'customer_id' => (int)$vehicle_obj->customer_id->value(),
                    'type' => (int)$vehicle_obj->type->value(),
                    'reg' => (string)$vehicle_obj->reg->value(),
                    'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(),
                ];
            };

            $format_wash_started_at = static function (?int $wash_start_time): ?string {
                if ($wash_start_time === null || $wash_start_time <= 0) {
                    return null;
                }

                return date('Y-m-d H:i:s', $wash_start_time);
            };
            $selfserve = new selfserve();
            $included_minutes_when_machine_enabled = (int)$selfserve
                ->config
                ->machine_wash_minutes_included
                ->getVariableValue();
            if ($included_minutes_when_machine_enabled < 0) {
                $included_minutes_when_machine_enabled = 0;
            }

            $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
                'lane_id' => $lane_id,
                'completed_at' => null,
                'deleted_at' => null,
            ], ['id']);
            $session = null;
            if ($rows !== []) {
                usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
                $tmp_session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']);
                if ($tmp_session->exists()) {
                    $session = $tmp_session;
                }
            }

            if ($session === null) {
                $lane = $selfserve->lane($lane_id);
                $lane_status = $lane->getLaneStatus();
                $lane_state = $lane->getLaneState();
                $wash_started_at = $format_wash_started_at($lane->getWashStartTime());
                $machine_start_triggered = $wash_started_at !== null;
                $machine_relay_enabled = $machine_start_triggered
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED)
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON)
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN_QUEUED)
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN);
                $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null;
                $in_progress = $lane_status->equals(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED)
                    || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);

                if (!$in_progress) {
                    $response->success($this->scopeInProgressWashResponseForCustomer([
                        'lane_id' => $lane_id,
                        'in_progress' => false,
                        'session' => null,
                        'customer' => null,
                        'vehicle' => null,
                    ], $customer_scope));
                }

                $runtime_customer_number = (int)$lane->getCustomerNumber();
                if ($runtime_customer_number <= 0) {
                    $runtime_customer_number = null;
                }
                $runtime_reg = trim((string)$lane->getLicensePlate());
                if ($runtime_reg === '') {
                    $runtime_reg = null;
                }

                $customer = $build_customer($runtime_customer_number);
                $vehicle = $build_vehicle(null, $runtime_reg);
                $response->success($this->scopeInProgressWashResponseForCustomer([
                    'lane_id' => $lane_id,
                    'in_progress' => true,
                    'session' => [
                        'id' => null,
                        'status' => $lane_state->name,
                        'reg' => $runtime_reg,
                        'customer_number' => $runtime_customer_number,
                        'subuser_id' => null,
                        'vehicle_id' => $vehicle['id'] ?? null,
                        'vehicle_type_id' => $vehicle['type'] ?? null,
                        'included_minutes' => $included_minutes,
                        'machine_type_id' => null,
                        'machine_relay_enabled' => $machine_relay_enabled,
                        'machine_relay_enabled_at' => $machine_relay_enabled ? $wash_started_at : null,
                        'machine_start_triggered' => $machine_start_triggered,
                        'machine_start_triggered_at' => $wash_started_at,
                        'wash_started_at' => $wash_started_at,
                        'created_at' => null,
                        'updated_at' => null,
                ],
                'customer' => $customer,
                'subuser' => null,
                'vehicle' => $vehicle,
            ], $customer_scope));
            }

            $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
            $subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
            $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
            $session_reg = trim((string)$session->reg->value());
            if ($session_reg === '') {
                $session_reg = null;
            }
            $customer = $build_customer($customer_number);
            $vehicle = $build_vehicle($vehicle_id, $session_reg);
            $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value();
            $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();
            if ($wash_started_at === null) {
                $wash_started_at = $machine_start_triggered_at;
            }
            if ($wash_started_at === null) {
                $wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());
            }
            $machine_relay_enabled = (bool)$session->machine_relay_enabled->value();
            $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null;
            $status = $session->status->value();
            $in_progress_statusses = [
                selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value,
                selfserve_wash_session_status::READY_FOR_MACHINE_START->value,
                selfserve_wash_session_status::MACHINE_STARTED->value,
                selfserve_wash_session_status::PENDING_QUESTIONS->value,
                selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value
            ];
            $in_progress = in_array($status, $in_progress_statusses);

            $response->success($this->scopeInProgressWashResponseForCustomer([
                'lane_id' => $lane_id,
                'status' => (string)$session->status->value(),
                'in_progress' => $in_progress,
                'elapsed_minutes' => $session->getElapsedMinutes(),
                'session' => [
                    'id' => (int)$session->id,
                    'status' => (string)$session->status->value(),
                    'reg' => $session_reg,
                    'customer_number' => $customer_number,
                    'subuser_id' => $subuser_id,
                    'vehicle_id' => $vehicle_id,
                    'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
                    'included_minutes' => $included_minutes ?? 0,
                    'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
                    'machine_relay_enabled' => $machine_relay_enabled,
                    'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(),
                    'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
                    'machine_start_triggered_at' => $machine_start_triggered_at,
                    'wash_started_at' => $wash_started_at,
                    'created_at' => (string)$session->created_at->value(),
                    'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
                ],
                'customer' => $customer,
                'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
                'vehicle' => $vehicle,
            ], $customer_scope));
        },
            [
                'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane',
                'add_own_department_selfserve_vehicle_conditions' => 'Use in-progress self-serve wash details for the authenticated customer',
            ]
        );

        /** Modules > Self Serve > Lane > Wash > My active wash */
        $this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
            global $response;

            $principal_scope = $this->requireMyActiveWashPrincipalScope();
            $session = $this->findLatestActiveSelfServeSessionForCustomer(
                (int)$principal_scope['customer_number'],
                $principal_scope['subuser_id']
            );
            if (!$session->exists()) {
                $response->error('No active self-serve wash found.', 404);
            }

            $response->success($this->buildActiveSelfServeSessionResponse($session));
        },
            [
                'add_own_department_selfserve_vehicle_conditions' => 'Use the authenticated customer\'s active self-serve wash',
            ]
        );

        /** Modules > Self Serve > Sessions */
        $this->get('/modules/self-serve/sessions', function () {
            global $response;
            self::requirePermission('modules_selfserve_sessions_view');

            $sessions = new selfserve_wash_sessions_o();
            $sessions->setSearchableFields([
                'id',
                'lane_id',
                'department_id',
                'machine_type_id',
                'customer_number',
                'subuser_id',
                'vehicle_id',
                'vehicle_type_id',
                'reg',
                'status',
                'order_id',
                'created_at',
                'completed_at',
            ]);

            $additional_where = null;
            if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) {
                $additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')';
            }

            $response->success($sessions->listObjectsWithPaginationIfSet(
                function (array $row): array {
                    $session = (new selfserve_wash_sessions_o())->select((int)$row['id']);
                    if (!$session->exists()) {
                        return $row;
                    }

                    return [
                        ...$session->asArray(),
                        'subuser' => $this->buildInProgressWashSubuserPayload(
                            $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value()
                        ),
                        'elapsed_minutes' => $session->getElapsedMinutes(),
                        'open' => $session->isOpen(),
                    ];
                },
                null,
                [],
                $additional_where
            ));
        },
            [
                'modules_selfserve_sessions_view' => 'View self-serve wash sessions',
            ]
        );

        /** Modules > Self Serve > Session detail */
        $this->get('/modules/self-serve/sessions/{id}', function () {
            global $response;
            self::requirePermission('modules_selfserve_sessions_view');
            $session_id = (int)$this->fromRoute('id');
            self::requireType($session_id, self::type_int());
            self::requireMinValue($session_id, 1);

            try {
                $response->success((new selfserve_wash_flow())->getSessionSummary($session_id));
            } catch (\Throwable $e) {
                $response->error($e->getMessage(), 404);
            }
        },
            [
                'modules_selfserve_sessions_view' => 'View self-serve wash session details',
            ]
        );

        /** Modules > Self Serve > Lane > Force > Stop */
        $this->post('/modules/self-serve/lane/force/stop', function () {
            global $response;
            self::requirePermission('modules_selfserve_sessions_force_stop');
            self::requireParameters(['lane_id', 'bill']);

            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $session_id = null;
            if ($this->isParametersSet(['session_id']) && $this->getParameter('session_id') !== null && $this->getParameter('session_id') !== '') {
                $session_id = (int)$this->getParameter('session_id');
                self::requireType($session_id, self::type_int());
                self::requireMinValue($session_id, 1);
            }

            $bill = $this->requestedBoolean('bill');
            if ($bill) {
                self::requirePermission('modules_selfserve_sessions_force_stop_bill');
            }
            $reason = null;
            if ($this->isParametersSet(['reason'])) {
                $reason = trim((string)$this->getParameter('reason'));
                $reason = $reason === '' ? null : $reason;
            }
            $user = (new authentication())->get_user();
            $user_id = $user instanceof users_o ? (int)$user->id : null;

            try {
                $response->success((new selfserve_wash_flow())->forceStopLane($lane_id, $session_id, $bill, $reason, $user_id));
            } catch (\Throwable $e) {
                $response->error($e->getMessage(), 409);
            }
        },
            [
                'modules_selfserve_sessions_force_stop' => 'Force stop a self-serve wash session without relay or gate signaling',
                'modules_selfserve_sessions_force_stop_bill' => 'Bill elapsed minutes when force stopping a self-serve wash session',
            ]
        );

        /** Modules > Self Serve > Lane > Command */
        $this->post('/modules/self-serve/lane/command', function () {
            global $response;
            $selfserve = new selfserve();
            // Get the request user
            $user = (new authentication())->get_user();
            $param_lane_id = 'lane_id';
            $param_command = 'command';
            // Validate parameters
            self::requireParameters([$param_lane_id, $param_command]);
            $lane_id = (int)$this->getParameter($param_lane_id);
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $commandParam = (string)$this->getParameter($param_command);
            self::requireType($commandParam, self::type_string());
            $command = selfserve_lane_command::tryFrom($commandParam);
            if ($command === null) {
                $response->error("Invalid command: " . $commandParam);
            }
            // Get the lane and command
            $lane = $selfserve->lane($lane_id);
            // Preserve not-found behavior before evaluating elevated/customer alternatives.
            if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
                $response->error('Lane department not found', 404);
            }
            $customer_number = $this->resolveEffectiveCustomerNumber();
            $customer_number = $customer_number === null ? 0 : (int)$customer_number;
            [
                $allow_customer_self_serve,
                $requires_active_wash,
                $allow_department_active_wash
            ] = $this->customerSelfServeCommandAccessRequirements($command);
            $this->requireSelfServeLaneDepartmentOrCustomerAccess(
                $lane,
                $customer_number,
                $allow_customer_self_serve,
                $requires_active_wash,
                $allow_department_active_wash
            );
            // If the user has the bypass permission, set the lane to bypass customer number validation
            if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) {
                $lane->setBypassCustomerNumberValidation(true);
            }
            // Require permissions for specific commands
            switch ($command) {
                case selfserve_lane_command::START:
                    $this->requireSelfServeLaneCommandPermission(
                        $lane,
                        $customer_number,
                        'modules_selfserve_lane_command_execute_start',
                        true
                    );
                    break;
                case selfserve_lane_command::STOP:
                    $this->requireSelfServeLaneCommandPermission(
                        $lane,
                        $customer_number,
                        'modules_selfserve_lane_command_execute_stop',
                        true,
                        true,
                        false
                    );
                    break;
                case selfserve_lane_command::RESERVE:
                    $this->requireOperatorLaneCommandPermission(
                        $lane,
                        'modules_selfserve_lane_command_execute_reserve'
                    );
                    break;
                case selfserve_lane_command::RELEASE:
                    $this->requireOperatorLaneCommandPermission(
                        $lane,
                        'modules_selfserve_lane_command_execute_release'
                    );
                    break;
                case selfserve_lane_command::RESET:
                    $this->requireOperatorLaneCommandPermission(
                        $lane,
                        'modules_selfserve_lane_command_execute_reset'
                    );
                    break;
                case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
                    $this->requirePropertyGateCommandPermission(
                        'modules_selfserve_lane_command_execute_open_property_access_gate',
                        $lane,
                        $customer_number
                    );
                    break;
                case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
                    $this->requirePropertyGateCommandPermission(
                        'modules_selfserve_lane_command_execute_open_property_exit_gate',
                        $lane,
                        $customer_number
                    );
                    break;
            }
            // Execute the command
            try {
                $this->applyShellyTransportOverride($lane);
                $subuser = (new authentication())->get_subuser();
                $args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
                $args->setParameters([
                    ...$this->getParametersAsArray(), // Pass all parameters
                    'customer_number' => $customer_number, // Get customer number from request user
                    'subuser_id' => $subuser === false ? null : (int)$subuser->id,
                    ]);
                $lane->execute($command, $args);
                if ($command === selfserve_lane_command::START) {
                    try {
                        $license_plate = selfserve::standardize_registration(
                            (string)($args->license_plate ?: $lane->getLicensePlate())
                        );
                        if ($license_plate !== '') {
                            (new selfserve_wash_flow())->synchronizeSession(
                                $lane_id,
                                $license_plate,
                                $customer_number > 0 ? $customer_number : null,
                                false,
                                null,
                                false,
                                ['subuser_id' => $args->subuser_id]
                            );
                        }
                    } catch (\Throwable $e) {
                        error_log(
                            'Failed to persist self-serve START session for lane '
                            . $lane_id
                            . ': '
                            . $e->getMessage()
                        );
                    }
                }
                $response->success([
                    'id' => $lane->id,
                    'status' => $lane->getLaneStatus()->name,
                    'mode' => $lane->getLaneMode()->name,
                    'state' => $lane->getLaneState()->name,
                    'wash_start_time' => $lane->getWashStartTime(),
                    'elapsed_wash_time' => $lane->getElapsedWashTime(),
                    'license_plate' => $lane->getLicensePlate(),
                    'customer_number' => $lane->getCustomerNumber(),
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                if (
                    $command === selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE ||
                    $command === selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE
                ) {
                    error_log(
                        'Failed to execute self-serve property gate command ' . $command->name .
                        ' for lane ' . $lane_id . ': ' . $e->getMessage()
                    );
                }
                $response->error("Failed to execute command: " . $e->getMessage());
            }
        },
            [
                'modules_selfserve_lane_command_execute' => 'Execute self-serve lane command. This is required together with specific command permissions below.',
                'modules_selfserve_lane_command_execute_start' => 'Execute self-serve lane START command',
                'modules_selfserve_lane_command_execute_stop' => 'Execute self-serve lane STOP command',
                'modules_selfserve_lane_command_execute_reserve' => 'Execute self-serve lane RESERVE command',
                'modules_selfserve_lane_command_execute_release' => 'Execute self-serve lane RELEASE command (Release the lane reservation and reset its state)',
                'modules_selfserve_lane_command_execute_reset' => 'Execute self-serve lane RESET command',
                'modules_selfserve_lane_command_execute_open_property_access_gate' => 'Execute self-serve lane OPEN_PROPERTY_ACCESS_GATE command',
                'modules_selfserve_lane_command_execute_open_property_exit_gate' => 'Execute self-serve lane OPEN_PROPERTY_EXIT_GATE command',
                'modules_selfserve_lane_command_bypass_customer_number_validation' => 'Bypass customer number validation when executing commands',
            ]
        );

        /** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
        $this->post('/modules/self-serve/lane/services/allowed', function () {
            global $response;
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            // Parse task_ids from request (optional, may be empty to clear)
            $task_ids_param = $this->isParametersSet(['task_ids']) ? $this->getParameter('task_ids') : [];
            if (is_string($task_ids_param)) {
                $decoded = json_decode($task_ids_param, true);
                if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
                    $task_ids_param = $decoded;
                } else {
                    $task_ids_param = array_filter(array_map(fn($s) => (int)trim($s), explode(',', $task_ids_param)), fn($v) => $v > 0);
                }
            }
            if (!is_array($task_ids_param)) {
                $response->error('Invalid format for task_ids. Expected array, JSON array, or comma-separated string of IDs.', 400);
            }
            $task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
            // Build allowed services from provided tasks
            $lane = $selfserve->lane($lane_id);
            $customer_number = $this->resolveEffectiveCustomerNumber();
            $this->requireSelfServeLaneAccess(
                $lane,
                $customer_number === null ? 0 : (int)$customer_number,
                ['modules_selfserve_lane_services_set_allowed'],
                false
            );
            $session_task_services = [];
            $latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
                $lane_id,
                $customer_number === null || (int)$customer_number <= 0 ? null : (int)$customer_number
            );
            if (!$latest_session->exists() && $customer_number !== null && (int)$customer_number > 0) {
                $latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane($lane_id);
            }
            if ($latest_session->exists()) {
                foreach ((new selfserve_wash_session_tasks_o())->listBySession((int)$latest_session->id) as $session_task) {
                    $task_id = (int)($session_task['task_id'] ?? 0);
                    if ($task_id > 0) {
                        $services = $session_task['services'] ?? [];
                        if (is_string($services)) {
                            $decoded_services = json_decode($services, true);
                            $services = json_last_error() === JSON_ERROR_NONE && is_array($decoded_services)
                                ? $decoded_services
                                : [];
                        }
                        $session_task_services[$task_id] = is_array($services) ? $services : [];
                    }
                }
            }

            $allowed_services = [];
            $published_config_task_services = $this->publishedConfigTaskServicesForLane($lane, $task_ids);
            $merge_services = static function (array $services) use (&$allowed_services): void {
                foreach ($services as $srv) {
                    $name = strtoupper((string)$srv);
                    if (!in_array($name, $allowed_services, true)) {
                        $allowed_services[] = $name;
                    }
                }
            };
            foreach ($task_ids as $tid) {
                if ($tid <= 0) continue;
                if (array_key_exists($tid, $session_task_services)) {
                    $merge_services($session_task_services[$tid]);
                    continue;
                }
                if (array_key_exists($tid, $published_config_task_services)) {
                    $merge_services($published_config_task_services[$tid]);
                    continue;
                }

                $t = new \objects\department_selfserve_tasks_o();
                $t->select($tid);
                if (!$t->exists()) continue; // ignore unknown ids
                // Validate the task belongs to the same lane
                if ((int)$t->lane->value() !== $lane_id) continue;
                // Merge services (if any)
                $merge_services((array)$t->services->value());
            }
            if (!$this->isMachineWashEnabled()) {
                $allowed_services = array_values(array_filter(
                    $allowed_services,
                    static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
                ));
            }
            // Persist on lane cache (overwrites previous allowed services)
            try {
                $relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services);
                $response->success([
                    'lane_id' => $lane_id,
                    'allowed_services' => $allowed_services,
                    'relay_sync' => $relay_sync,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to set allowed services: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)'
        ]);

        /** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */
        $this->post('/modules/self-serve/lane/gate/open', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_gate_open');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id', 'gate']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $gate_name = strtoupper(trim((string)$this->getParameter('gate')));
            $gate = match ($gate_name) {
                'ENTRANCE' => selfserve_lane_port::ENTRANCE,
                'EXIT' => selfserve_lane_port::EXIT,
                default => null,
            };
            if ($gate === null) {
                $response->error('Invalid gate value. Expected ENTRANCE or EXIT.', 400);
            }
            $toggle_after = $this->requestedRelayToggleAfter(1);

            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $queued_gate_command = null;
                if ($this->requestedShellyTransportOverride() === 'local') {
                    $queued_gate_command = $this->queueLocalLaneGateOpen($lane, $gate, $toggle_after);
                } else {
                    $lane->open($gate, $toggle_after);
                }

                $response->success([
                    'lane_id' => $lane_id,
                    'gate' => $gate->name,
                    'opened' => true,
                    'queued' => $queued_gate_command !== null,
                    'batch' => $queued_gate_command,
                    'toggle_after' => $toggle_after,
                    'state' => $lane->getLaneState()->name,
                    'transport' => $this->requestedShellyTransportOverride(),
                ], $queued_gate_command !== null ? 202 : 200);
            } catch (\Exception $e) {
                error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
                $response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
            }
        }, [
            'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
        ]);

        /** Modules > Self Serve > Lane > Hardware > Batch status */
        $this->post('/modules/self-serve/lane/hardware/batch/status', function () {
            global $response;
            $selfserve = new selfserve();
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $targets = self::isParametersSet(['targets']) ? (array)$this->getParameter('targets') : [
                    'MACHINE',
                    'PROGRAM_PICKER',
                    'CLEANER',
                    'ENTRANCE',
                    'EXIT',
                ];
                $requests = $this->buildLaneHardwareBatchRequests($lane, $targets, false);
                $user = (new authentication())->get_user();
                $result = (new edge_gateway_manager())->queueRelayStatusBatch(
                    $this->departmentIdForLane($lane),
                    $requests,
                    $user instanceof users_o ? (int)$user->id : null,
                    $this->requestedShellyTransportOverride() === 'local'
                );
                $response->success($result, 202);
            } catch (\Exception $e) {
                $response->error('Failed to queue lane hardware status batch: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_status_view' => 'Queue self-serve lane hardware status batch'
        ]);

        /** Modules > Self Serve > Lane > Hardware > Batch set/open */
        $this->post('/modules/self-serve/lane/hardware/batch/set', function () {
            global $response;
            $selfserve = new selfserve();
            self::requireParameters(['lane_id', 'commands']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $commands = (array)$this->getParameter('commands');
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $requests = $this->buildLaneHardwareBatchRequests($lane, $commands, true);
                $user = (new authentication())->get_user();
                $result = (new edge_gateway_manager())->queueRelaySwitchBatch(
                    $this->departmentIdForLane($lane),
                    $requests,
                    $user instanceof users_o ? (int)$user->id : null,
                    $this->requestedShellyTransportOverride() === 'local'
                );
                $response->success($result, 202);
            } catch (\Exception $e) {
                $response->error('Failed to queue lane hardware command batch: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_status_set' => 'Queue self-serve lane hardware command batch'
        ]);

        /** Modules > Self Serve > Lane > Hardware > Batch poll */
        $this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
            try {
                $response->success((new edge_gateway_manager())->relayBatchStatus((string)$this->fromRoute('batch_id')));
            } catch (\Exception $e) {
                $response->error('Failed to get lane hardware batch status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_status_view' => 'Poll self-serve lane hardware batch'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
        $this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $lane = $selfserve->lane($lane_id);

            try {
                $this->applyShellyTransportOverride($lane);
                $status = $lane->getMachineProgramPickerRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status));
            } catch (\Exception $e) {
                $response->error('Failed to get MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_program_picker_status_view' => 'Get MACHINE_PROGRAM_PICKER relay status for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
        $this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id', 'on']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $raw_on = $this->getParameter('on');
            $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            if ($on === null) {
                $response->error('Invalid on value. Expected boolean true/false.', 400);
            }

            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->setMachineProgramPickerRelayStatus((bool)$on);
                $status = $lane->getMachineProgramPickerRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status, [
                    'requested_on' => (bool)$on,
                ]));
            } catch (\Exception $e) {
                $response->error('Failed to set MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_program_picker_status_set' => 'Set MACHINE_PROGRAM_PICKER relay status (on/off) for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
        $this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $lane = $selfserve->lane($lane_id);

            try {
                $this->applyShellyTransportOverride($lane);
                $status = $lane->getMachineCleanerRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status));
            } catch (\Exception $e) {
                $response->error('Failed to get MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_cleaner_status_view' => 'Get MACHINE_CLEANER relay status for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
        $this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id', 'on']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $raw_on = $this->getParameter('on');
            $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            if ($on === null) {
                $response->error('Invalid on value. Expected boolean true/false.', 400);
            }

            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->setMachineCleanerRelayStatus((bool)$on);
                $status = $lane->getMachineCleanerRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status, [
                    'requested_on' => (bool)$on,
                ]));
            } catch (\Exception $e) {
                $response->error('Failed to set MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_cleaner_status_set' => 'Set MACHINE_CLEANER relay status (on/off) for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE status */
        $this->get('/modules/self-serve/lane/relay/machine/status', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $lane = $selfserve->lane($lane_id);

            try {
                $this->applyShellyTransportOverride($lane);
                $status = $lane->getMachineRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status));
            } catch (\Exception $e) {
                $response->error('Failed to get MACHINE relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_status_view' => 'Get MACHINE relay status for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
        $this->post('/modules/self-serve/lane/relay/machine/set', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_machine_status_set');
            $selfserve = new selfserve();
            self::requireParameters(['lane_id', 'on']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);

            $raw_on = $this->getParameter('on');
            $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            if ($on === null) {
                $response->error('Invalid on value. Expected boolean true/false.', 400);
            }

            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->setMachineRelayStatus((bool)$on);
                // Keep lane cache state aligned with the latest explicit relay action
                try {
                    $machine_relay_id = '';
                    if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
                        $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
                    }
                    $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
                    $lane->setLaneState((bool)$on
                        ? ($is_demo_machine_relay
                            ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
                            : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON)
                        : ($is_demo_machine_relay
                            ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
                            : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF)
                    );
                } catch (\Throwable $ignored) {}

                $status = $lane->getMachineRelayStatus();
                $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status, [
                    'requested_on' => (bool)$on,
                ]));
            } catch (\Exception $e) {
                $response->error('Failed to set MACHINE relay status: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_machine_status_set' => 'Set MACHINE relay status (on/off) for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
        $this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_PROGRAM_PICKER',
                    'enabled' => true,
                    'duration' => $duration,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_enable_machine_program_picker' => 'Manually enable MACHINE_PROGRAM_PICKER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
        $this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_CLEANER',
                    'enabled' => true,
                    'duration' => $duration,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_relay_enable_machine_cleaner' => 'Manually enable MACHINE_CLEANER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
        $this->post('/modules/self-serve/lane/relay/machine/enable', function () {
            global $response;
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $lane = $selfserve->lane($lane_id);
            $customer_number = $this->resolveEffectiveCustomerNumber();
            self::requirePermission('modules_selfserve_lane_relay_enable_machine');
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
                // A started wash should also turn on cleaner when configured.
                try {
                    if (
                        !empty($lane->department_lane)
                        && !empty($lane->department_lane->relay_machine_cleaner_id)
                        && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
                    ) {
                        $lane->setMachineCleanerRelayStatusHard(true);
                    }
                } catch (\Throwable $ignored) {}
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE',
                    'enabled' => true,
                    'duration' => $duration,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $msg = $e->getMessage();
                // If gating prevented activation, respond with 403
                if (str_contains(strtoupper($msg), 'NOT ALLOWED')) {
                    $response->error($msg, 403);
                }
                $response->error('Failed to enable MACHINE relay: ' . $msg, 400);
            }
        }, [
            'modules_selfserve_lane_relay_enable_machine' => 'Manually enable MACHINE relay for a lane (requires allowed task to be present)'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
        $this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_PROGRAM_PICKER',
                    'enabled' => true,
                    'duration' => $duration,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_program_picker_enable' => 'Force enable MACHINE_PROGRAM_PICKER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
        $this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_PROGRAM_PICKER',
                    'disabled' => true,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force disable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_program_picker_disable' => 'Force disable MACHINE_PROGRAM_PICKER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
        $this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_CLEANER',
                    'enabled' => true,
                    'duration' => $duration,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_cleaner_enable' => 'Force enable MACHINE_CLEANER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
        $this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER);
                $response->success([
                    'lane_id' => $lane_id,
                    'relay' => 'MACHINE_CLEANER',
                    'disabled' => true,
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force disable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_cleaner_disable' => 'Force disable MACHINE_CLEANER relay for a lane'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
        $this->post('/modules/self-serve/lane/force/machine/enable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_enable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $duration = null;
            if ($this->isParametersSet(['duration'])) {
                $duration = (int)$this->getParameter('duration');
                self::requireMinValue($duration, 1);
            }
            $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null;
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                // If lane looks idle/available, mark as occupied and in wash, set timers
                if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) {
                    $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
                }
                if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) {
                    $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
                }
                if ($license_plate !== null && $license_plate !== '') {
                    $lane->setLicensePlate($license_plate);
                }
                if ((int)$lane->getWashStartTime() <= 0) {
                    $lane->setWashStartTime(time());
                }
                // A started wash should always have cleaner relay enabled when configured.
                try {
                    if (
                        !empty($lane->department_lane)
                        && !empty($lane->department_lane->relay_machine_cleaner_id)
                        && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
                    ) {
                        $lane->setMachineCleanerRelayStatusHard(true);
                    }
                } catch (\Throwable $ignored) {}
                // Force enable the machine relay (bypass gating)
                $lane->forceTurnOnMachineRelay($duration);
                // Reflect relay state explicitly
                try {
                    $machine_relay_id = '';
                    if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
                        $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
                    }
                    $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
                    $lane->setLaneState(
                        $is_demo_machine_relay
                            ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
                            : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON
                    );
                } catch (\Throwable $ignored) {}
                $response->success([
                    'lane_id' => $lane_id,
                    'forced' => true,
                    'machine' => 'ENABLED',
                    'duration' => $duration,
                    'status' => $lane->getLaneStatus()->name,
                    'state' => $lane->getLaneState()->name,
                    'wash_start_time' => $lane->getWashStartTime(),
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force-enable MACHINE: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_enable' => 'Force enable MACHINE relay and mark lane as in-wash (superusers only)'
        ]);

        /** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */
        $this->post('/modules/self-serve/lane/force/machine/disable', function () {
            global $response;
            self::requirePermission('modules_selfserve_lane_force_machine_disable');
            $selfserve = new selfserve();
            // Validate parameters
            self::requireParameters(['lane_id']);
            $lane_id = (int)$this->getParameter('lane_id');
            self::requireType($lane_id, self::type_int());
            self::requireMinValue($lane_id, 1);
            $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null;
            $lane = $selfserve->lane($lane_id);
            try {
                $this->applyShellyTransportOverride($lane);
                // Ensure lane reflects an ongoing wash, but with machine off
                if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) {
                    $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
                }
                if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) {
                    $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
                }
                if ($license_plate !== null && $license_plate !== '') {
                    $lane->setLicensePlate($license_plate);
                }
                if ((int)$lane->getWashStartTime() <= 0) {
                    $lane->setWashStartTime(time());
                }
                // A started wash should always have cleaner relay enabled when configured.
                try {
                    if (
                        !empty($lane->department_lane)
                        && !empty($lane->department_lane->relay_machine_cleaner_id)
                        && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
                    ) {
                        $lane->setMachineCleanerRelayStatusHard(true);
                    }
                } catch (\Throwable $ignored) {}
                // Turn off the machine relay (do not swallow errors)
                $lane->forceTurnOffMachineRelay();
                // Reflect relay state explicitly
                try {
                    $machine_relay_id = '';
                    if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
                        $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
                    }
                    $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
                    $lane->setLaneState(
                        $is_demo_machine_relay
                            ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
                            : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF
                    );
                } catch (\Throwable $ignored) {}
                $response->success([
                    'lane_id' => $lane_id,
                    'forced' => true,
                    'machine' => 'DISABLED',
                    'status' => $lane->getLaneStatus()->name,
                    'state' => $lane->getLaneState()->name,
                    'wash_start_time' => $lane->getWashStartTime(),
                    'transport' => $this->requestedShellyTransportOverride(),
                ]);
            } catch (\Exception $e) {
                $response->error('Failed to force-disable MACHINE: ' . $e->getMessage(), 400);
            }
        }, [
            'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)'
        ]);
    }

    private function requireInProgressWashDetailsAccess(int $lane_id): ?array
    {
        global $response;

        if (!$this->hasAuthenticatedUserOrSubuser()) {
            $response->error('Authentication failed. Invalid or missing token.', 401);
        }

        if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) {
            $department_lane = (new department_lanes_o())->select($lane_id);
            if (!$department_lane->exists()) {
                $response->error('Department lane not found', 404);
            }
            self::requireDepartmentAccess((string)$department_lane->department->value());
            return null;
        }

        $customer_number = $this->resolveEffectiveCustomerNumber();
        if (
            $customer_number !== null
            && $customer_number > 0
            && self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)
        ) {
            return [
                'customer_number' => (int)$customer_number,
                'subuser_id' => $this->authenticatedSubuserId(),
            ];
        }

        $this->emitForbidden([
            'modules_selfserve_lane_wash_in_progress_view',
            $this->customerSelfServeUsePermission(),
        ]);
        return null;
    }

    /**
     * Customers poll all visible lanes to restore their own active wash. Keep that
     * poll successful without exposing another customer's session details.
     *
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    protected function scopeInProgressWashResponseForCustomer(array $payload, mixed $customer_scope): array
    {
        $scope = $this->normalizeCustomerScope($customer_scope);
        $customer_number = $scope['customer_number'];
        $subuser_id = $scope['subuser_id'];

        if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) {
            return $payload;
        }

        $session_customer_number = $this->extractInProgressWashCustomerNumber($payload);
        $session_subuser_id = $this->extractInProgressWashSubuserId($payload);
        if ($session_customer_number === $customer_number && ($subuser_id === null || $session_subuser_id === $subuser_id)) {
            return $payload;
        }

        return [
            'lane_id' => (int)($payload['lane_id'] ?? 0),
            'in_progress' => true,
            'session' => null,
            'customer' => null,
            'vehicle' => null,
        ];
    }

    /**
     * @return array{customer_number: ?int, subuser_id: ?int}
     */
    private function normalizeCustomerScope(mixed $customer_scope): array
    {
        if (is_array($customer_scope)) {
            return [
                'customer_number' => $this->normalizePositiveInt($customer_scope['customer_number'] ?? null),
                'subuser_id' => $this->normalizePositiveInt($customer_scope['subuser_id'] ?? null),
            ];
        }

        return [
            'customer_number' => $this->normalizePositiveInt($customer_scope),
            'subuser_id' => null,
        ];
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function extractInProgressWashCustomerNumber(array $payload): ?int
    {
        $candidates = [
            $payload['session']['customer_number'] ?? null,
            $payload['customer']['customer_number'] ?? null,
        ];

        foreach ($candidates as $candidate) {
            if ($candidate === null || $candidate === '') {
                continue;
            }
            $customer_number = (int)$candidate;
            if ($customer_number > 0) {
                return $customer_number;
            }
        }

        return null;
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function extractInProgressWashSubuserId(array $payload): ?int
    {
        $candidates = [
            $payload['session']['subuser_id'] ?? null,
            $payload['session']['subuser']['id'] ?? null,
            $payload['subuser']['id'] ?? null,
        ];

        foreach ($candidates as $candidate) {
            $subuser_id = $this->normalizePositiveInt($candidate);
            if ($subuser_id !== null) {
                return $subuser_id;
            }
        }

        return null;
    }

    /**
     * @return array{customer_number: int, subuser_id: ?int}
     */
    private function requireMyActiveWashPrincipalScope(): array
    {
        global $response;

        if (!$this->hasAuthenticatedUserOrSubuser()) {
            $response->error('Authentication failed. Invalid or missing token.', 401);
        }

        $customer_number = $this->resolveEffectiveCustomerNumber();
        if (!self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)) {
            $this->emitForbidden([$this->customerSelfServeUsePermission()]);
        }

        if ($customer_number === null || $customer_number <= 0) {
            $response->error('No customer number found for authenticated user.', 404);
        }

        return [
            'customer_number' => (int)$customer_number,
            'subuser_id' => $this->authenticatedSubuserId(),
        ];
    }

    private function customerSelfServePermission(): \classes\permission_node
    {
        return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
    }

    private function customerSelfServeUsePermission(): \classes\permission_node
    {
        return self::definePermission(self::CUSTOMER_SELFSERVE_USE_PERMISSION, subusers_permission_node_key::SELFSERVE_ADD);
    }

    private function authenticatedSubuserId(): ?int
    {
        try {
            $subuser = (new authentication())->get_subuser();
            return $subuser === false ? null : (int)$subuser->id;
        } catch (\Throwable) {
            return null;
        }
    }

    private function normalizePositiveInt(mixed $value): ?int
    {
        if ($value === null || $value === '') {
            return null;
        }

        $normalized = (int)$value;
        return $normalized > 0 ? $normalized : null;
    }

    private function hasAuthenticatedUserOrSubuser(): bool
    {
        $auth = new authentication();
        return $auth->get_user() !== false || $auth->get_subuser() !== false;
    }

    private function findLatestActiveSelfServeSessionForCustomer(int $customer_number, ?int $subuser_id = null): selfserve_wash_sessions_o
    {
        if ($customer_number <= 0) {
            return new selfserve_wash_sessions_o();
        }

        $filters = [
            'customer_number' => $customer_number,
            'completed_at' => null,
            'deleted_at' => null,
        ];
        if ($subuser_id !== null) {
            $filters['subuser_id'] = $subuser_id;
        }

        $rows = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);

        $active_statuses = $this->activeSelfServeWashSessionStatusValues();
        $rows = array_values(array_filter(
            $rows,
            static fn(array $row): bool => in_array((string)($row['status'] ?? ''), $active_statuses, true)
        ));
        if ($rows === []) {
            return new selfserve_wash_sessions_o();
        }

        usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
        return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']);
    }

    /**
     * @return array<int,string>
     */
    private function activeSelfServeWashSessionStatusValues(): array
    {
        return array_map(
            static fn(selfserve_wash_session_status $status): string => $status->value,
            [
                selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
                selfserve_wash_session_status::READY_FOR_MACHINE_START,
                selfserve_wash_session_status::MACHINE_STARTED,
                selfserve_wash_session_status::PENDING_QUESTIONS,
                selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
            ]
        );
    }

    /**
     * @return array<string,mixed>
     */
    private function buildActiveSelfServeSessionResponse(selfserve_wash_sessions_o $session): array
    {
        $lane_id = (int)$session->lane_id->value();
        $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
        $subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
        $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
        $session_reg = trim((string)$session->reg->value());
        if ($session_reg === '') {
            $session_reg = null;
        }

        $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value();
        $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();
        if ($wash_started_at === null) {
            $wash_started_at = $machine_start_triggered_at;
        }

        $machine_relay_enabled = (bool)$session->machine_relay_enabled->value();
        $included_minutes = null;
        if ($machine_relay_enabled) {
            $included_minutes = (int)(new selfserve())->config->machine_wash_minutes_included->getVariableValue();
            if ($included_minutes < 0) {
                $included_minutes = 0;
            }
        }

        return [
            'lane_id' => $lane_id,
            'status' => (string)$session->status->value(),
            'in_progress' => true,
            'elapsed_minutes' => $session->getElapsedMinutes(),
            'session' => [
                'id' => (int)$session->id,
                'status' => (string)$session->status->value(),
                'department_id' => $session->department_id->value() === null ? null : (int)$session->department_id->value(),
                'lane_id' => $lane_id,
                'reg' => $session_reg,
                'customer_number' => $customer_number,
                'subuser_id' => $subuser_id,
                'vehicle_id' => $vehicle_id,
                'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
                'included_minutes' => $included_minutes ?? 0,
                'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
                'machine_relay_enabled' => $machine_relay_enabled,
                'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(),
                'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
                'machine_start_triggered_at' => $machine_start_triggered_at,
                'wash_started_at' => $wash_started_at,
                'created_at' => (string)$session->created_at->value(),
                'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
            ],
            'customer' => $this->buildInProgressWashCustomerPayload($customer_number),
            'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
            'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg),
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    private function buildInProgressWashCustomerPayload(?int $customer_number): ?array
    {
        if ($customer_number === null || $customer_number <= 0) {
            return null;
        }

        $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number);
        if ($customer_obj->exists()) {
            return [
                'id' => (int)$customer_obj->id,
                'customer_number' => $customer_number,
                'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(),
                'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(),
                'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(),
                'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(),
            ];
        }

        return [
            'id' => null,
            'customer_number' => $customer_number,
            'display_name' => null,
            'email' => null,
            'phone_country_code' => null,
            'phone' => null,
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    private function buildInProgressWashSubuserPayload(?int $subuser_id): ?array
    {
        if ($subuser_id === null || $subuser_id <= 0) {
            return null;
        }

        try {
            $subuser = (new subusers_o())->select($subuser_id);
            if (!$subuser->exists()) {
                return [
                    'id' => $subuser_id,
                ];
            }

            return [
                'id' => (int)$subuser->id,
                'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
                'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
            ];
        } catch (\Throwable) {
            return [
                'id' => $subuser_id,
            ];
        }
    }

    /**
     * @return array<string,mixed>|null
     */
    private function buildInProgressWashVehiclePayload(?int $vehicle_id, ?string $reg): ?array
    {
        $vehicle_obj = null;
        if ($vehicle_id !== null && $vehicle_id > 0) {
            $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id);
            if ($tmp_vehicle->exists()) {
                $vehicle_obj = $tmp_vehicle;
            }
        }
        if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') {
            $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg));
            if ($tmp_vehicle->exists()) {
                $vehicle_obj = $tmp_vehicle;
            }
        }
        if ($vehicle_obj === null) {
            return null;
        }

        return [
            'id' => (int)$vehicle_obj->id,
            'customer_id' => (int)$vehicle_obj->customer_id->value(),
            'type' => (int)$vehicle_obj->type->value(),
            'reg' => (string)$vehicle_obj->reg->value(),
            'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(),
        ];
    }

    /**
     * @param array<string,mixed> $status
     * @param array<string,mixed> $extra
     * @return array<string,mixed>
     */
    private function buildRelayStatusResponse(int $lane_id, string $relay, array $status, array $extra = []): array
    {
        $on = (bool)($status['on'] ?? false);
        $switch_status = is_array($status['status'] ?? null) ? $status['status'] : [];
        if (!isset($switch_status['switch:0']) || !is_array($switch_status['switch:0'])) {
            $switch_status['switch:0'] = [];
        }
        $switch_status['switch:0']['output'] = (bool)($switch_status['switch:0']['output'] ?? $on);

        $payload = [
            'lane_id' => $lane_id,
            'relay' => $relay,
            ...$extra,
            'relay_id' => (string)($status['relay_id'] ?? ''),
            'online' => (bool)($status['online'] ?? false),
            'on' => $on,
            'status' => $switch_status,
            'transport' => $this->requestedShellyTransportOverride(),
        ];

        foreach (['binding', 'execution', 'raw'] as $metadata_key) {
            if (isset($status[$metadata_key]) && is_array($status[$metadata_key]) && $status[$metadata_key] !== []) {
                $payload[$metadata_key] = $status[$metadata_key];
            }
        }

        return $payload;
    }

    /**
     * @return array<string,mixed>
     * @throws \Exception
     */
    private function queueLocalLaneGateOpen(selfserve_lane $lane, selfserve_lane_port $gate, int $toggleAfter): array
    {
        $requests = $this->buildLaneHardwareBatchRequests($lane, [[
            'target' => $gate->name,
            'action' => 'OPEN',
            'toggle_after' => $toggleAfter,
        ]], true);
        $user = (new authentication())->get_user();

        return (new edge_gateway_manager())->queueRelaySwitchBatch(
            $this->departmentIdForLane($lane),
            $requests,
            $user instanceof users_o ? (int)$user->id : null,
            true
        );
    }

    /**
     * @param array<int,mixed> $items
     * @return array<int,array<string,mixed>>
     * @throws \Exception
     */
    private function buildLaneHardwareBatchRequests(selfserve_lane $lane, array $items, bool $mutation): array
    {
        if (empty($lane->department_lane)) {
            throw new \Exception("Department lane object not found for lane ID {$lane->id}");
        }

        $requests = [];
        $seen = [];
        foreach ($items as $item) {
            $command = is_array($item) ? $item : ['target' => $item];
            $target = strtoupper(trim((string)($command['target'] ?? $command['relay'] ?? '')));
            if ($target === '') {
                throw new \Exception('Lane hardware batch commands require a target');
            }
            if (isset($seen[$target])) {
                throw new \Exception('Duplicate lane hardware target: ' . $target);
            }
            $seen[$target] = true;

            $relayId = $this->relayIdForLaneHardwareTarget($lane, $target);
            if ($relayId === '') {
                throw new \Exception('No relay is configured for lane hardware target ' . $target);
            }

            if ($mutation) {
                $this->requireLaneHardwareMutationPermission($target);
            } else {
                $this->requireLaneHardwareStatusPermission($target);
            }

            $request = [
                'target' => $target,
                'relay_id' => $relayId,
                'relayId' => $relayId,
                'lane_id' => (int)$lane->id,
                'laneId' => (int)$lane->id,
            ];

            if ($mutation) {
                if (in_array($target, ['ENTRANCE', 'EXIT'], true)) {
                    $action = strtoupper(trim((string)($command['action'] ?? 'OPEN')));
                    if ($action !== 'OPEN') {
                        throw new \Exception($target . ' only supports action=OPEN');
                    }
                    $request['on'] = true;
                    $request['toggle_after'] = $this->normalizeBatchToggleAfter(
                        $command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'] ?? 1,
                        self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS
                    );
                } else {
                    if (!array_key_exists('on', $command)) {
                        throw new \Exception($target . ' relay commands require an on boolean');
                    }
                    $on = is_bool($command['on'])
                        ? $command['on']
                        : filter_var($command['on'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
                    if ($on === null) {
                        throw new \Exception('Invalid on value for ' . $target . '. Expected boolean true/false.');
                    }
                    $request['on'] = (bool)$on;
                    if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) {
                        $request['toggle_after'] = $this->normalizeBatchToggleAfter(
                            $command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'],
                            86400
                        );
                    }
                }
            }

            $requests[] = $request;
        }

        if ($requests === []) {
            throw new \Exception('At least one lane hardware target is required');
        }
        if (count($requests) > 5) {
            throw new \Exception('Lane hardware batches are limited to five targets');
        }

        return $requests;
    }

    private function relayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
    {
        $relayId = trim((string)match ($target) {
            'MACHINE' => $lane->department_lane->relay_machine_id->value(),
            'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => $lane->department_lane->relay_machine_program_picker_id->value(),
            'CLEANER', 'MACHINE_CLEANER' => $lane->department_lane->relay_machine_cleaner_id->value(),
            'ENTRANCE' => $lane->department_lane->relay_in_id->value(),
            'EXIT' => $lane->department_lane->relay_out_id->value(),
            default => throw new \Exception('Unsupported lane hardware target: ' . $target),
        });

        if ($relayId !== '') {
            return $relayId;
        }

        return $this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target);
    }

    private function edgeGatewayRelayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
    {
        $departmentId = $this->departmentIdForLane($lane);
        if ($departmentId <= 0) {
            return '';
        }

        $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
            'department_id' => $departmentId,
            'deleted_at' => null,
        ], ['id']);

        $laneScoped = [];
        $unscoped = [];
        foreach ($rows as $row) {
            $binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
            if (!$binding->exists()) {
                continue;
            }

            $payload = $binding->asArray();
            if (!$this->edgeGatewayBindingMatchesHardwareTarget($payload, $target)) {
                continue;
            }

            if ($this->edgeGatewayBindingMatchesLane($payload, (int)$lane->id, $target)) {
                $laneScoped[] = (string)$payload['relay_id'];
                continue;
            }

            if ($this->edgeGatewayBindingHasConsumers($payload)) {
                continue;
            }

            $unscoped[] = (string)$payload['relay_id'];
        }

        $candidates = $laneScoped !== [] ? array_values(array_unique($laneScoped)) : array_values(array_unique($unscoped));
        if (count($candidates) === 1) {
            return $candidates[0];
        }
        if (count($candidates) > 1) {
            throw new \Exception('Multiple edge gateway relay bindings match lane hardware target ' . $target);
        }

        return '';
    }

    /**
     * @param array<string,mixed> $binding
     */
    private function edgeGatewayBindingMatchesHardwareTarget(array $binding, string $target): bool
    {
        $accepted = match ($target) {
            'MACHINE' => ['MACHINE'],
            'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
            'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
            'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
            'EXIT' => ['EXIT'],
            default => [],
        };
        if ($accepted === []) {
            return false;
        }

        $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
        $values = [
            $binding['role'] ?? null,
            $binding['service'] ?? null,
            $binding['slot'] ?? null,
            $metadata['role'] ?? null,
            $metadata['relay_role'] ?? null,
            $metadata['service'] ?? null,
            $metadata['slot'] ?? null,
        ];
        foreach ((array)($binding['services'] ?? $metadata['services'] ?? []) as $service) {
            $values[] = $service;
        }

        foreach ($values as $value) {
            if (in_array($this->normalizeLaneHardwareTargetRole($value), $accepted, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $binding
     */
    private function edgeGatewayBindingMatchesLane(array $binding, int $laneId, string $target): bool
    {
        $targetRoles = match ($target) {
            'MACHINE' => ['MACHINE'],
            'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
            'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
            'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
            'EXIT' => ['EXIT'],
            default => [],
        };
        $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
        $consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
        if (!is_array($consumers)) {
            return false;
        }

        foreach ($consumers as $consumer) {
            if (!is_array($consumer)) {
                continue;
            }
            if (strtolower(trim((string)($consumer['type'] ?? ''))) !== 'lane') {
                continue;
            }
            if ((int)($consumer['id'] ?? 0) !== $laneId) {
                continue;
            }
            $slot = $this->normalizeLaneHardwareTargetRole($consumer['slot'] ?? '');
            if ($slot === '' || in_array($slot, $targetRoles, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $binding
     */
    private function edgeGatewayBindingHasConsumers(array $binding): bool
    {
        $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
        $consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
        return is_array($consumers) && $consumers !== [];
    }

    private function normalizeLaneHardwareTargetRole(mixed $value): string
    {
        return strtoupper(str_replace(['-', ' '], '_', trim((string)($value ?? ''))));
    }

    private function requireLaneHardwareStatusPermission(string $target): void
    {
        match ($target) {
            'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_view'),
            'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'),
            'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'),
            'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
            default => throw new \Exception('Unsupported lane hardware target: ' . $target),
        };
    }

    private function requireLaneHardwareMutationPermission(string $target): void
    {
        match ($target) {
            'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_set'),
            'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'),
            'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'),
            'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
            default => throw new \Exception('Unsupported lane hardware target: ' . $target),
        };
    }

    private function normalizeBatchToggleAfter(mixed $value, int $max): int
    {
        $seconds = (int)$value;
        if ($seconds <= 0) {
            throw new \Exception('toggle_after must be greater than zero');
        }

        return min($seconds, $max);
    }

    /**
     * @throws \Exception
     */
    private function applyShellyTransportOverride(object $lane): void
    {
        $transport = $this->requestedShellyTransportOverride();
        if ($transport === null) {
            return;
        }

        if (!method_exists($lane, 'setShellyTransportOverride')) {
            throw new \Exception('This lane does not support Shelly transport overrides.');
        }

        $lane->setShellyTransportOverride($transport);
    }

    /**
     * @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed]
     */
    private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array
    {
        return match ($command) {
            selfserve_lane_command::START => [true, false, false],
            selfserve_lane_command::STOP => [true, true, false],
            selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE,
            selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true],
            selfserve_lane_command::RESERVE,
            selfserve_lane_command::RELEASE,
            selfserve_lane_command::RESET => [false, false, false],
        };
    }

    private function requireSelfServeLaneDepartmentOrCustomerAccess(
        selfserve_lane $lane,
        int $customer_number,
        bool $allow_customer_self_serve,
        bool $requires_active_wash = false,
        bool $allow_department_active_wash = false
    ): void {
        $department_id = $this->departmentIdForLane($lane);
        if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) {
            return;
        }

        if ($allow_customer_self_serve) {
            if ($requires_active_wash && $allow_department_active_wash) {
                $customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
            } else {
                $customer_allowed = $requires_active_wash
                    ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false)
                    : $this->canCustomerUseSelfServeLane($lane, $customer_number);
            }

            if ($customer_allowed) {
                return;
            }
        }

        $missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
        if ($allow_customer_self_serve) {
            $missing_permissions[] = $requires_active_wash
                ? $this->customerSelfServePermission()
                : $this->customerSelfServeUsePermission();
        }
        $this->emitForbidden($missing_permissions);
    }

    /**
     * @param array<int,string> $permissions
     */
    private function hasAllPermissions(array $permissions): bool
    {
        foreach ($permissions as $permission) {
            if (!$this->hasPermission($permission)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<int,string> $elevated_permissions
     */
    private function requireSelfServeLaneAccess(
        selfserve_lane $lane,
        int $customer_number,
        array $elevated_permissions,
        bool $requires_active_wash = false,
        bool $requires_operational_lane = true
    ): void {
        if ($this->hasAllPermissions($elevated_permissions)) {
            return;
        }

        if ($requires_active_wash) {
            $customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
                && (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
        } else {
            $customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
        }

        if ($customer_allowed) {
            return;
        }

        $this->emitForbidden([
            ...$elevated_permissions,
            $requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
        ]);
    }

    private function requireSelfServeLaneCommandPermission(
        selfserve_lane $lane,
        int $customer_number,
        string $command_permission,
        bool $allow_customer_self_serve,
        bool $requires_active_wash = false,
        bool $allow_department_active_wash = false
    ): void {
        $elevated_permissions = [
            'modules_selfserve_lane_command_execute',
            $command_permission,
        ];
        if ($this->hasAllPermissions($elevated_permissions)) {
            return;
        }

        if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) {
            $customer_allowed = $requires_active_wash
                ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
                : $this->canCustomerUseSelfServeLane($lane, $customer_number);

            if ($customer_allowed) {
                return;
            }
        }

        $this->emitForbidden(
            $allow_customer_self_serve
                ? [
                    ...$elevated_permissions,
                    $requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
                ]
                : $elevated_permissions
        );
    }

    private function requireOperatorLaneCommandPermission(selfserve_lane $lane, string $command_permission): void
    {
        if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
            global $response;
            $response->error('Lane department not found', 404);
        }

        self::requireDepartmentAccess((string)$lane->department_lane->department->value());
        self::requirePermission('modules_selfserve_lane_command_execute');
        self::requirePermission($command_permission);
    }

    private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
    {
        $elevated_permissions = [
            'modules_selfserve_lane_command_execute',
            $permission,
        ];
        if ($this->hasAllPermissions($elevated_permissions)) {
            return;
        }

        if ($this->canCustomerUsePropertyGateForLane($lane, $customer_number)) {
            return;
        }

        $this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
    }

    protected function isSelfServeModuleEnabled(): bool
    {
        try {
            return (new selfserve())->config->enabled->isTrue();
        } catch (\Throwable) {
            return false;
        }
    }

    protected function isMachineWashEnabled(): bool
    {
        try {
            return (new selfserve())->config->machine_wash_enabled->isTrue();
        } catch (\Throwable) {
            return true;
        }
    }

    protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
    {
        return $customer_number > 0
            && $this->hasPermission($this->customerSelfServeUsePermission(), $customer_number)
            && $this->isLaneSelfServeOperationallyEnabled($lane);
    }

    protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
    {
        if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
            return false;
        }

        $subuser_id = $this->authenticatedSubuserId();
        try {
            if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
                return true;
            }
        } catch (\Throwable) {
            // Fall back to the persisted session lookup below.
        }

        $department_id = $this->departmentIdForLane($lane);
        return $department_id > 0
            && $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id);
    }

    protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
    {
        if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) {
            return true;
        }

        if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) {
            return false;
        }

        $department_id = $this->departmentIdForLane($lane);
        return $department_id > 0
            && $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $this->authenticatedSubuserId());
    }

    protected function canCustomerUseActiveOperationalSelfServeLane(
        selfserve_lane $lane,
        int $customer_number,
        bool $allow_department_active_wash = false
    ): bool {
        return $this->isLaneSelfServeOperationallyEnabled($lane)
            && (
                $allow_department_active_wash
                    ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
                    : $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number)
            );
    }

    protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
    {
        if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
            return false;
        }

        $subuser_id = $this->authenticatedSubuserId();
        try {
            if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
                return true;
            }
        } catch (\Throwable) {
            // Fall back to the persisted lane session lookup below.
        }

        $active_statuses = array_map(
            static fn(selfserve_wash_session_status $status): string => $status->value,
            [
                selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
                selfserve_wash_session_status::READY_FOR_MACHINE_START,
                selfserve_wash_session_status::MACHINE_STARTED,
                selfserve_wash_session_status::PENDING_QUESTIONS,
                selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
            ]
        );

        $filters = [
            'lane_id' => (int)$lane->id,
            'customer_number' => $customer_number,
            'completed_at' => null,
            'deleted_at' => null,
        ];
        if ($subuser_id !== null) {
            $filters['subuser_id'] = $subuser_id;
        }

        $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);

        foreach ($sessions as $session) {
            if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
                return true;
            }
        }

        return false;
    }

    protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
    {
        try {
            if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
                return false;
            }
        } catch (\Throwable) {
            return false;
        }

        $department_id = $this->departmentIdForLane($lane);
        if ($department_id <= 0) {
            return false;
        }

        try {
            $department = (new departments_o())->select($department_id);
            return $department->exists() && $department->getSelfServeEnabled();
        } catch (\Throwable) {
            return false;
        }
    }

    protected function departmentIdForLane(selfserve_lane $lane): int
    {
        try {
            return (int)$lane->department_lane?->department?->value();
        } catch (\Throwable) {
            return 0;
        }
    }

    protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool
    {
        if ($department_id <= 0 || $customer_number <= 0) {
            return false;
        }

        $active_statuses = array_map(
            static fn(selfserve_wash_session_status $status): string => $status->value,
            [
                selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
                selfserve_wash_session_status::READY_FOR_MACHINE_START,
                selfserve_wash_session_status::MACHINE_STARTED,
                selfserve_wash_session_status::PENDING_QUESTIONS,
                selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
            ]
        );

        $filters = [
            'department_id' => $department_id,
            'customer_number' => $customer_number,
            'completed_at' => null,
            'deleted_at' => null,
        ];
        if ($subuser_id !== null) {
            $filters['subuser_id'] = $subuser_id;
        }

        $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);

        foreach ($sessions as $session) {
            if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
                return true;
            }
        }

        foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) {
            if ($subuser_id !== null) {
                continue;
            }
            try {
                $lane = (new selfserve())->lane((int)$department_lane->id);
                if ((int)$lane->getCustomerNumber() !== $customer_number) {
                    continue;
                }

                if (
                    $lane->getLaneStatus()->equals(selfserve_lane_status::OCCUPIED)
                    || $lane->getLaneState()->equals(selfserve_lane_state::IN_WASH)
                ) {
                    return true;
                }
            } catch (\Throwable) {
                continue;
            }
        }

        return false;
    }

    /**
     * @param array<int,int> $task_ids
     * @return array<int,array<int,string>>
     */
    private function publishedConfigTaskServicesForLane(selfserve_lane $lane, array $task_ids): array
    {
        $department_id = $this->departmentIdForLane($lane);
        if ($department_id <= 0 || $task_ids === []) {
            return [];
        }

        $published = (new selfserve_config_versioning())->getPublishedConfig($department_id);
        $config = is_array($published) ? (array)($published['config'] ?? []) : [];
        $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
        if ($tasks === []) {
            return [];
        }

        $requested_task_ids = array_fill_keys(array_map(static fn($task_id): int => (int)$task_id, $task_ids), true);
        $services_by_task_id = [];
        foreach ($tasks as $task) {
            if (!is_array($task)) {
                continue;
            }

            $task_id = (int)($task['id'] ?? $task['task_id'] ?? 0);
            if ($task_id <= 0 || !isset($requested_task_ids[$task_id])) {
                continue;
            }

            $task_department_id = (int)($task['department'] ?? $task['department_id'] ?? 0);
            if ($task_department_id !== 0 && $task_department_id !== $department_id) {
                continue;
            }

            $task_lane_id = (int)($task['lane'] ?? $task['lane_id'] ?? 0);
            if ($task_lane_id !== 0 && $task_lane_id !== (int)$lane->id) {
                continue;
            }

            $services = $task['services'] ?? [];
            if (is_string($services)) {
                $decoded = json_decode($services, true);
                $services = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : [];
            }

            $services_by_task_id[$task_id] = is_array($services) ? $services : [];
        }

        return $services_by_task_id;
    }

    private function requestedShellyTransportOverride(): ?string
    {
        $transport = null;
        foreach (['transport', 'test_transport', 'transport_mode'] as $parameter) {
            if (self::isParametersSet([$parameter])) {
                $transport = self::getParameter($parameter);
                break;
            }
        }

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

        if ($normalized === 'gateway') {
            return 'local';
        }

        if (!in_array($normalized, ['local', 'cloud'], true)) {
            throw new \InvalidArgumentException('Invalid transport. Expected local, gateway, or cloud.');
        }

        return $normalized;
    }

    private function requestedRelayToggleAfter(int $default_seconds): int
    {
        $toggle_after = $default_seconds;
        foreach (['toggle_after', 'toggleAfter', 'timer'] as $parameter) {
            if (self::isParametersSet([$parameter])) {
                $toggle_after = (int)self::getParameter($parameter);
                break;
            }
        }

        self::requireMinValue($toggle_after, 1);
        self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS);
        return $toggle_after;
    }

    private function machineStatusAuditUserName(?object $user): ?string
    {
        if (!$user) {
            return null;
        }

        foreach (['display_name', 'email'] as $property) {
            if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
                continue;
            }

            $value = trim((string)$user->{$property}->value());
            if ($value !== '' && strtolower($value) !== 'unnamed') {
                return $value;
            }
        }

        if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
            $customer_number = (int)$user->customer_number->value();
            if ($customer_number > 0) {
                return 'Kunde ' . $customer_number;
            }
        }

        return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
    }

    private function requestedBoolean(string $parameter, bool $default = false): bool
    {
        if (!self::isParametersSet([$parameter])) {
            return $default;
        }

        $value = self::getParameter($parameter);
        if (is_bool($value)) {
            return $value;
        }
        if (is_int($value)) {
            return $value === 1;
        }
        $normalized = strtolower(trim((string)$value));
        return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
    }
}
