<?php

namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
if (!class_exists(\modules\selfserve\classes\selfserve_wash_flow::class, false)) {
    require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
}
if (!class_exists(\modules\selfserve\classes\selfserve_studio_action_runner::class, false)) {
    require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
}
if (!class_exists(\modules\selfserve\classes\selfserve_studio_actions::class, false)) {
    require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
}

use Exception;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\classes\selfserve_studio_action_runner;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_log_action;
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_state;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\helpers\selfserve_lane_relay;
use objects\department_gates_o;
use objects\users_o;
use objects\department_variables_o;

trait selfserve_lane_command_t
{

    /**
     * Acquire an atomic per-lane START lock before performing physical side effects.
     */
    protected function acquireLaneStartCommandLock(): string
    {
        if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) {
            throw new \RuntimeException('Cannot start lane: START lock is unavailable.');
        }

        $token = bin2hex(random_bytes(16));
        $lock_key = $this->getLaneStartCommandLockKey();
        if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) {
            throw new \RuntimeException("Cannot start lane: Lane is not available.");
        }

        return $token;
    }

    protected function releaseLaneStartCommandLock(string $token): void
    {
        if (!defined('redis')) {
            return;
        }

        $lock_key = $this->getLaneStartCommandLockKey();
        try {
            if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) {
                return;
            }
            if (method_exists(redis, 'delete')) {
                redis->delete($lock_key);
            }
        } catch (\Throwable) {
            // The lock has a short TTL, so release failures must not mask START results.
        }
    }

    protected function getLaneStartCommandLockKey(): string
    {
        return 'selfserve_lane_start_command_lock_' . (int)$this->id;
    }

    /**
     * Determine if the lane and its department have self-serve enabled.
     * This method is intentionally protected to allow tests to override
     * and avoid I/O when needed.
     */
    protected function isDepartmentSelfServeEnabled(): bool
    {
        try {
            if (empty($this->department_lane) || empty($this->department_lane->department)) {
                return false;
            }
            $departmentId = (int)$this->department_lane->department->value();
            if ($departmentId <= 0) return false;
            $vars = (new department_variables_o())->selectDepartment($departmentId);
            return $vars->getVariable('selfserve_enabled') === true
                && $this->department_lane->isSelfServeEnabled();
        } catch (\Throwable $e) {
            // If anything goes wrong, default to not enabled
            return false;
        }
    }

    /**
     * Resolve whether the program selector relay is currently online.
     * Fail-closed to false when relay status cannot be read.
     */
    protected function isProgramSelectorRelayOnlineForStop(): bool
    {
        try {
            $status = $this->getMachineProgramPickerRelayStatus();
            return (bool)($status['online'] ?? false);
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Machine-wash billing is based on the physical machine ON signal, not selector relay status.
     */
    protected function hasMachineStartSignalForStop(?int $subuser_id = null): bool
    {
        try {
            $customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
            return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
                (int)$this->id,
                method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
                $customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
                $subuser_id
            );
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Append the lane vehicle-type product to the current invoice order when requested.
     */
    protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void
    {
        if (!$should_add) {
            return;
        }

        try {
            $this->addVehicleTypeProductToLastInvoiceOrder();
        } catch (\Throwable) {
            // Best effort only; invoice correction can be handled manually if needed.
        }
    }

    /**
     * Enable cleaner relay when a wash is started.
     * Best-effort and skipped when cleaner relay is not configured.
     */
    protected function turnOnCleanerRelayForWashStart(): void
    {
        if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_CLEANER)) {
            return;
        }

        try {
            $this->setMachineCleanerRelayStatusHard(true);
        } catch (\Throwable) {
            // Best effort only; wash start must continue.
        }
    }
    /**
     * Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
     * If machine relay is not configured, this is a no-op.
     */
    protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
    {
        if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
            return;
        }
        if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
            try {
                $this->setMachineRelayStatusHard(true);
            } catch (\Throwable) {
                // Best effort only; wash start must continue.
            }
        } else {
            // Turning on the machine relay is not allowed by configuration, so ensure it is OFF.
            try {
                $this->setMachineRelayStatusHard(false);
            } catch (\Throwable) {
                // Best effort only; wash start must continue.
            }
        }
    }

    /**
     * Keep the program picker relay aligned with the selected wash mode at START.
     * It is ON only when the customer explicitly selected machine wash.
     */
    protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
    {
        if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
            return;
        }

        try {
            $shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
                && $this->isMachineWashSelectedAndAvailableForStart($arguments);
            $this->setMachineProgramPickerRelayStatusHard($shouldEnable);
        } catch (\Throwable) {
            // Best effort only; wash start must continue.
        }
    }

    protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
    {
        if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
            return;
        }

        try {
            $this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
        } catch (\Throwable) {
            // Best effort only; wash start must continue.
        }
    }

    protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
    {
        if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
            return false;
        }

        try {
            $reg = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
            if ($reg === '') {
                return false;
            }

            $customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
            $snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
                (int)$this->id,
                $reg,
                $customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
                null,
                ['subuser_id' => $arguments?->subuser_id]
            );

            return (bool)($snapshot['allowed'] ?? false);
        } catch (\Throwable) {
            return false;
        }
    }

    protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
    {
        if (!$this->isMachineWashModeSelectedForStart($arguments)) {
            return false;
        }

        return $this->isMachineServiceSelectedForWashStart();
    }

    protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
    {
        return $this->isExplicitMachineWashModeSelectedForStart($arguments)
            && $this->isMachineServiceSelectedForWashStart();
    }

    protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
    {
        return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
    }

    protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
    {
        if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
            return false;
        }

        if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
            return true;
        }

        return $this->isMachineServiceSelectedForWashStart();
    }

    protected function isMachineServiceSelectedForWashStart(): bool
    {
        try {
            if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
                $services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
                if (is_array($services)) {
                    foreach ($services as $service) {
                        if (strtoupper((string)$service) === 'MACHINE') {
                            return true;
                        }
                    }
                }
            }
        } catch (\Throwable) {
            // Fall through to fail-closed when the selected service cache is unavailable.
        }

        return false;
    }

    protected function openEntrancePortForWashStart(): void
    {
        try {
            $this->open(selfserve_lane_port::ENTRANCE);
        } catch (\Throwable $e) {
            if ($this->isAmbiguousGatewayTimeout($e)) {
                $this->reportWashStartEntranceTimeout($e);
                return;
            }

            throw $e;
        }
    }

    protected function isAmbiguousGatewayTimeout(\Throwable $e): bool
    {
        $current = $e;
        while ($current !== null) {
            $message = strtolower(trim($current->getMessage()));
            if (
                str_contains($message, 'edge gateway command timed out') ||
                str_contains($message, 'command timed out') ||
                str_contains($message, 'timed out') ||
                str_contains($message, 'timeout')
            ) {
                return true;
            }

            $current = $current->getPrevious();
        }

        return false;
    }

    protected function reportWashStartEntranceTimeout(\Throwable $e): void
    {
        try {
            $laneId = isset($this->id) ? (string)$this->id : 'unknown';
            error_log(
                'Self-serve START entrance gate dispatch timed out for lane ' .
                $laneId .
                '; continuing wash start because the gateway command may already have reached the relay: ' .
                $e->getMessage()
            );
        } catch (\Throwable) {
            // Diagnostics must not block the user wash start flow.
        }
    }

    protected function openExitPortForWashStop(): void
    {
        try {
            $this->open(selfserve_lane_port::EXIT);
        } catch (\Throwable $e) {
            if ($this->isAmbiguousGatewayTimeout($e)) {
                $this->reportWashStopExitTimeout($e);
                return;
            }

            throw $e;
        }
    }

    protected function reportWashStopExitTimeout(\Throwable $e): void
    {
        try {
            $laneId = isset($this->id) ? (string)$this->id : 'unknown';
            error_log(
                'Self-serve STOP exit gate dispatch timed out for lane ' .
                $laneId .
                '; continuing wash stop because the gateway command may already have reached the relay: ' .
                $e->getMessage()
            );
        } catch (\Throwable) {
            // Diagnostics must not block the user wash stop flow.
        }
    }

    protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
    {
        if ($arguments->defer_relay_side_effects) {
            $this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
            return;
        }

        $this->turnOnCleanerRelayForWashStart();
        $this->setProgramPickerRelayStatusForWashStart($arguments);
        $this->setMachineRelayStatusForWashStart($arguments);
    }

    protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
    {
        if ($arguments !== null && in_array($arguments->wash_mode, [
            selfserve_studio_actions::MODE_MANUAL,
            selfserve_studio_actions::MODE_MACHINE,
        ], true)) {
            return $arguments->wash_mode;
        }

        if ($this->isMachineServiceSelectedForWashStart()) {
            return selfserve_studio_actions::MODE_MACHINE;
        }

        return selfserve_studio_actions::MODE_MANUAL;
    }

    /**
     * Execute configured Studio actions from the published flow for this lane.
     *
     * @param array<string,mixed> $context
     * @return array<int,array<string,mixed>>
     */
    protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array
    {
        return (new selfserve_studio_action_runner())->executeForLaneEvent(
            $this,
            $event,
            $washMode,
            $this->buildPublishedStudioActionContext($context)
        );
    }

    /**
     * @param array<string,mixed> $context
     * @return array<string,mixed>
     */
    protected function buildPublishedStudioActionContext(array $context): array
    {
        if (!array_key_exists('lane_id', $context)) {
            $context['lane_id'] = (int)$this->id;
        }

        $reg = trim((string)($context['reg'] ?? ''));
        if ($reg === '' && method_exists($this, 'getLicensePlate')) {
            $reg = trim((string)$this->getLicensePlate());
            if ($reg !== '') {
                $context['reg'] = $reg;
            }
        }

        $customerNumber = $context['customer_number'] ?? null;
        if (($customerNumber === null || (int)$customerNumber <= 0) && method_exists($this, 'getCustomerNumber')) {
            $resolvedCustomerNumber = (int)$this->getCustomerNumber();
            if ($resolvedCustomerNumber > 0) {
                $customerNumber = $resolvedCustomerNumber;
                $context['customer_number'] = $resolvedCustomerNumber;
            }
        }

        if ($reg === '') {
            return $context;
        }

        try {
            $preview = (new selfserve_wash_flow())->previewVehicleEligibility(
                (int)$this->id,
                $reg,
                $customerNumber === null || (int)$customerNumber <= 0 ? null : (int)$customerNumber
            );
            if (!isset($context['condition_results']) && is_array($preview['evaluation_trace']['condition_results'] ?? null)) {
                $context['condition_results'] = (array)$preview['evaluation_trace']['condition_results'];
            }
            if (!isset($context['visibility_condition_results']) && is_array($preview['evaluation_trace']['visibility_condition_results'] ?? null)) {
                $context['visibility_condition_results'] = (array)$preview['evaluation_trace']['visibility_condition_results'];
            }
            if (!isset($context['allowed_services']) && is_array($preview['allowed_services'] ?? null)) {
                $context['allowed_services'] = (array)$preview['allowed_services'];
            }
            if (!isset($context['vehicle_type_id']) && array_key_exists('vehicle_type_id', $preview)) {
                $context['vehicle_type_id'] = $preview['vehicle_type_id'];
            }
            if (!isset($context['product']) && array_key_exists('vehicle_type_id', $preview)) {
                $context['product'] = $preview['vehicle_type_id'];
            }
            if (!isset($context['machine_type_id']) && is_array($preview['machine_type'] ?? null)) {
                $context['machine_type_id'] = $preview['machine_type']['id'] ?? null;
            }
        } catch (\Throwable) {
            // Studio actions remain best-effort for legacy command flows.
        }

        return $context;
    }

    /**
     * Disable relays before opening exit gates in deterministic order:
     * 1. Cleaner relay
     * 2. Program picker relay
     * 3. Machine relay
     */
    protected function turnOffRelaysAfterStop(): void
    {
        $relays = [
            selfserve_lane_relay::MACHINE_CLEANER,
            selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
            selfserve_lane_relay::MACHINE,
        ];

        foreach ($relays as $relay) {
            if (!$this->isRelayConfigured($relay)) {
                continue;
            }

            try {
                $this->setRelayStatusHard($relay, false);
            } catch (\Throwable) {
                // Continue attempting to turn off remaining relays.
            }
        }
    }

    protected function isRelayConfigured(selfserve_lane_relay $relay): bool
    {
        if (empty($this->department_lane)) {
            return false;
        }

        $relay_id = match ($relay) {
            selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(),
            selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(),
            selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(),
        };

        return trim($relay_id) !== '';
    }

    /**
     * Finalize any active self-serve wash session before resetting lane state.
     */
    protected function completeLatestSessionForStop(?int $subuser_id = null): void
    {
        try {
            (new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
                $this->id,
                $this->getLicensePlate() ?: null,
                $this->getCustomerNumber() ?: null,
                method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
                false,
                $subuser_id
            );
        } catch (\Throwable) {
            // Session completion must not block STOP flow.
        }
    }

    protected function executeOpenPropertyGateCommand(bool $isAccessGate): void
    {
        $commandLabel = $isAccessGate ? 'access' : 'exit';
        $gateLabel = $isAccessGate ? 'entrance' : 'exit';

        if (!$this->isDepartmentSelfServeEnabled()) {
            throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane.');
        }
        if (empty($this->department_lane) || empty($this->department_lane->department)) {
            throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');
        }

        $department_id = (int)$this->department_lane->department->value();
        if ($department_id <= 0) {
            throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');
        }

        $gate = $this->resolveDepartmentGateForCommand($department_id, $isAccessGate);
        if ($gate === null || !$gate->exists()) {
            throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: No ' . $gateLabel . ' gate configured for this lane\'s department.');
        }

        try {
            $this->openDepartmentGateForCommand($gate);
        } catch (\Throwable $e) {
            $this->reportPropertyGateCommandFailure($commandLabel, $e);
            throw new \RuntimeException($this->propertyGateCommandFailureMessage($isAccessGate), 0, $e);
        }
    }

    protected function resolveDepartmentGateForCommand(int $department_id, bool $isAccessGate): ?department_gates_o
    {
        $department_gates = new department_gates_o();
        return $isAccessGate
            ? $department_gates->getEntranceGate($department_id)
            : $department_gates->getExitGate($department_id);
    }

    /**
     * @throws Exception
     */
    protected function openDepartmentGateForCommand(department_gates_o $gate): void
    {
        $gate->openGate();
    }

    protected function propertyGateCommandFailureMessage(bool $isAccessGate): string
    {
        return $isAccessGate
            ? 'Failed to open property access gate.'
            : 'Failed to open property exit gate.';
    }

    protected function reportPropertyGateCommandFailure(string $commandLabel, \Throwable $e): void
    {
        try {
            $laneId = isset($this->id) ? (string)$this->id : 'unknown';
            error_log('Self-serve property ' . $commandLabel . ' gate open failed for lane ' . $laneId . ': ' . $e->getMessage());
        } catch (\Throwable) {
            // Never block API flow on diagnostics logging.
        }
    }

    /**
     * Execute a command on a self-serve lane
     * @param selfserve_lane_command $command The command to execute
     * @param selfserve_lane_command_arguments $arguments The arguments for the command
     * @return selfserve_lane|selfserve_lane_command_t
     * @throws Exception If the command cannot be executed
     * @throws \Throwable
     */
    public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
    {
        // Handle the command
        switch ($command) {
            case selfserve_lane_command::RESERVE:
                // Require lane to be available before reserving
                if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot reserve lane: Lane is not available.");
                // Require the customer number
                if (empty($customer_number = $arguments->customer_number)) throw new \InvalidArgumentException("Customer number is required to reserve the lane.");
                // Validate customer number
                if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
                if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
                // Set the customer number
                $this->setCustomerNumber($customer_number);
                // Set the lane status to RESERVED when reserved
                $this->setLaneStatus(selfserve_lane_status::RESERVED);
                // Set the reservation time
                $this->setReservationStartTime(time());
                // Log the lane reserve event
                $this->logLaneAction(selfserve_lane_log_action::RESERVE_LANE);
                break;
            case selfserve_lane_command::RELEASE:
                // Require lane to be reserved before releasing
                if (!$this->getLaneStatus()->equals(selfserve_lane_status::RESERVED)) throw new \RuntimeException("Cannot release lane: Lane is not reserved.");
                // Check if the customer number equals the argument customer number
                if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
                    throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
                }
                // Reset the lane
                self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
                // Log the lane release event
                $this->logLaneAction(selfserve_lane_log_action::RELEASE_LANE);
                break;
            case selfserve_lane_command::START:
                // Require lane to be available before starting
                if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
                // Require the customer number
                if (empty($customer_number = $arguments->customer_number)) throw new \InvalidArgumentException("Customer number is required to start the lane.");
                // Require the license plate
                if (empty($license_plate = $arguments->license_plate)) throw new \InvalidArgumentException("License plate is required to start the lane.");
                // Validate customer number
                if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
                if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
                $start_lock_token = $this->acquireLaneStartCommandLock();
                try {
                    // Re-check availability after taking the START lock so concurrent requests cannot
                    // both pass the preflight check and trigger the physical entrance relay.
                    if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
                    $previous_customer_number = $this->getCustomerNumber();
                    $previous_license_plate = $this->getLicensePlate();
                    $previous_status = $this->getLaneStatus();
                    // Set the customer number and license plate
                    $this->setCustomerNumber($customer_number);
                    $this->setLicensePlate($license_plate);
                    // Mark the lane occupied before any physical relay or gate side effects.
                    $this->setLaneStatus(selfserve_lane_status::OCCUPIED);
                    $this->runRelaySideEffectsForWashStart($arguments);
                    $this->runPublishedStudioActions(
                        selfserve_studio_actions::EVENT_WASH_START_COMMAND,
                        $this->resolveSelfServeActionWashModeForStart($arguments),
                        [
                            'customer_number' => (int)$customer_number,
                            'reg' => $license_plate,
                        ]
                    );
                    try {
                        // Gateway timeouts are ambiguous because the relay may already have received
                        // the pulse, so openEntrancePortForWashStart() reports them and continues.
                        $this->openEntrancePortForWashStart();
                    } catch (\Throwable $e) {
                        $this->setCustomerNumber($previous_customer_number);
                        $this->setLicensePlate($previous_license_plate);
                        $this->setLaneStatus($previous_status);
                        $this->setLaneState(selfserve_lane_state::IDLE);
                        throw $e;
                    }
                    // Set the lane state to IN_WASH
                    $this->setLaneState(selfserve_lane_state::IN_WASH);
                    // Start the wash timer
                    $this->setWashStartTime(time());
                    // Log the lane start event
                    $this->logLaneAction(selfserve_lane_log_action::START_WASH);
                } finally {
                    $this->releaseLaneStartCommandLock($start_lock_token);
                }
                break;
            case selfserve_lane_command::STOP:
                // Require lane to be occupied before stopping
                if (!$this->getLaneStatus()->equals(selfserve_lane_status::OCCUPIED)) throw new \RuntimeException("Cannot stop lane: Lane is not occupied.");
                // Check if the customer number equals the argument customer number
                if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
                    throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
                }
                // Snapshot the physical machine ON signal before session completion/reset.
                $machine_start_triggered = $this->hasMachineStartSignalForStop($arguments->subuser_id);
                // Turn off relays before any configured or default exit gate opens.
                $this->turnOffRelaysAfterStop();
                $this->runPublishedStudioActions(
                    selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
                    $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
                    [
                        'customer_number' => $arguments->customer_number,
                        'reg' => $this->getLicensePlate(),
                        'machine_start_triggered' => $machine_start_triggered,
                    ]
                );
                // Open the exit port. Gateway timeouts are ambiguous because
                // the relay may already have received the pulse.
                $this->openExitPortForWashStop();
                // Log the lane stop event
                $this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
                // Invoice the customer
                $this->invoice($arguments);
                // Only bill the machine wash product when the physical machine start signal was recorded.
                $this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
                // Finalize any active self-serve wash session for this lane
                $this->completeLatestSessionForStop($arguments->subuser_id);
                // Reset the lane
                self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
                break;
            case selfserve_lane_command::RESET:
                // Reset the lane to default states
                $this->setLaneStatus(selfserve_lane_status::AVAILABLE);
                $this->setLaneMode(selfserve_lane_mode::MANUAL);
                $this->setLaneState(selfserve_lane_state::IDLE);
                $this->setWashStartTime(self::DEFAULT_WASH_START_TIME);
                $this->setCustomerNumber(self::DEFAULT_CUSTOMER_NUMBER);
                $this->setLicensePlate(self::DEFAULT_LICENSE_PLATE);
                $this->setReservationStartTime(null);
                break;
            case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
                $this->executeOpenPropertyGateCommand(true);
                break;
            case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
                $this->executeOpenPropertyGateCommand(false);
                break;
            default:
                throw new \InvalidArgumentException("Unknown command: " . $command->name);
        }
        return $this;
    }
}
