<?php

namespace modules\selfserve\classes;

require_once WD . '/classes/selfserve.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_event_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
require_once WD . '/objects/customer_vehicles_o.php';
if (!class_exists(\objects\department_lanes_o::class, false)) {
    require_once WD . '/objects/department_lanes_o.php';
}
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/department_selfserve_vehicle_conditions_o.php';
require_once WD . '/objects/selfserve_machine_types_o.php';
require_once WD . '/objects/selfserve_wash_session_answers_o.php';
require_once WD . '/objects/selfserve_wash_session_events_o.php';
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
require_once WD . '/objects/subusers_o.php';

use classes\selfserve;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_studio_action_runner;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
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_event_type;
use modules\selfserve\helpers\selfserve_wash_session_status;
use modules\selfserve\interfaces\selfserve_condition_evaluator_i;
use modules\selfserve\interfaces\selfserve_wash_flow_i;
use objects\customer_vehicles_o;
use objects\department_lanes_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
use objects\department_selfserve_vehicle_conditions_o;
use objects\selfserve_machine_types_o;
use objects\selfserve_wash_session_answers_o;
use objects\selfserve_wash_session_events_o;
use objects\selfserve_wash_session_tasks_o;
use objects\selfserve_wash_sessions_o;
use objects\subusers_o;

class selfserve_wash_flow implements selfserve_wash_flow_i
{
    public function __construct(
        protected ?selfserve_condition_evaluator_i $conditionEvaluator = null,
    ) {
        selfserve_schema_bootstrap::ensureTables();
        $this->conditionEvaluator ??= new selfserve_condition_evaluator();
    }

    public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
    {
        $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
        $actorSubuserId = $this->resolveActorSubuserId($options);
        $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);

        return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
    }

    /**
     * @param array<string,mixed> $options
     * @return array<string,mixed>
     */
    public function previewStudioSimulation(int $departmentId, int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
    {
        $options['debug'] = true;
        $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
        $actorSubuserId = $this->resolveActorSubuserId($options);
        $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
        $response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
        $response['simulator_version'] = 2;
        $response['dry_run'] = true;
        $response['mode'] = 'full_dry_run';
        $response['config_source'] = (string)($options['config_source'] ?? 'draft');
        $response['debug'] = $this->buildStudioDebugPayload($departmentId, $snapshot, $options);

        return $response;
    }

    public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
    {
        $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
        $actorSubuserId = $this->resolveActorSubuserId($options);
        $mutationResult = $this->withSessionMutationLock(
            $laneId,
            $snapshot['reg'],
            $snapshot['customer_number'],
            $actorSubuserId,
            function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
                $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
                $createSession = (bool)($options['create_session'] ?? true);

                if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
                    return [
                        'session' => $session,
                        'response' => $session->exists()
                            ? $this->getSessionSummary((int)$session->id)
                            : $this->formatBlockedSessionSummary($snapshot),
                    ];
                }

                if (!$session->exists() && !$createSession) {
                    return [
                        'session' => $session,
                        'response' => $this->formatSnapshotResponse($snapshot, null),
                    ];
                }

                if (!$session->exists()) {
                    $session = (new selfserve_wash_sessions_o())->add(
                        $laneId,
                        (int)$snapshot['lane']['department'],
                        $snapshot['machine_type']['id'] ?? null,
                        $snapshot['customer_number'],
                        $snapshot['reg'],
                        $snapshot['vehicle']['id'] ?? null,
                        $snapshot['vehicle']['type'] ?? null,
                        $this->deriveBaseStatus($snapshot),
                        (bool)$snapshot['allowed'],
                        $this->buildSessionMetadata($snapshot),
                        $actorSubuserId,
                    );
                } else {
                    $session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
                    $session->customer_number->set($snapshot['customer_number']);
                    if ($actorSubuserId !== null) {
                        $currentSubuserId = $this->nullableInt($session->subuser_id->value());
                        if ($currentSubuserId === null || $currentSubuserId === $actorSubuserId) {
                            $session->subuser_id->set($actorSubuserId);
                        }
                    }
                    $session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
                    $session->vehicle_type_id->set($snapshot['vehicle_type_id']);
                    $session->reg->set($snapshot['reg']);
                    $session->allowed->set((bool)$snapshot['allowed']);
                    $session->metadata_json->set($this->buildSessionMetadata($snapshot));
                    $session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
                }

                $this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
                $this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
                $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
                    'allowed' => (bool)$snapshot['allowed'],
                    'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
                    'allowed_services' => $snapshot['allowed_services'],
                    'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
                    'subuser_id' => $actorSubuserId,
                ]);

                return [
                    'session' => $session,
                    'response' => null,
                ];
            }
        );

        $session = $mutationResult['session'];
        if ($mutationResult['response'] !== null) {
            return $mutationResult['response'];
        }

        if ($syncRelayState) {
            if ($session->exists()) {
                $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
            }
        }

        return $this->getSessionSummary((int)$session->id);
    }

    public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array
    {
        $normalizedReg = $reg === null ? null : selfserve::standardize_registration($reg);
        $session = $normalizedReg !== null
            ? $this->findLatestOpenSession($laneId, $normalizedReg)
            : $this->findLatestOpenSessionByLane($laneId);

        if (!$session->exists()) {
            if ($normalizedReg === null) {
                throw new \RuntimeException('No active self-serve wash session found for the lane.');
            }
            $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);
            if (empty($summary['session']['id'])) {
                throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.'));
            }
            $session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']);
        }

        $lane = (new selfserve())->lane($laneId);
        $effectiveReg = $normalizedReg ?? (string)$session->reg->value();
        $customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
        $subuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();

        if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
            $lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
        }
        if (!$lane->getLaneState()->equals(selfserve_lane_state::IN_WASH)) {
            $lane->setLaneState(selfserve_lane_state::IN_WASH);
        }
        if ($effectiveReg !== '') {
            $lane->setLicensePlate($effectiveReg);
        }
        if ($customerNumber !== null && $customerNumber > 0) {
            $lane->setCustomerNumber($customerNumber);
        }
        if ((int)$lane->getWashStartTime() <= 0) {
            $lane->setWashStartTime(time());
        }
        $washStartedAt = (int)$lane->getWashStartTime();

        $session->markMachineStartTriggered(
            $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null
        );
        $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [
            'lane_id' => $laneId,
            'reg' => $effectiveReg,
            'customer_number' => $customerNumber,
            'subuser_id' => $subuserId,
        ]);
        $actionContext = [
            'lane_id' => $laneId,
            'reg' => $effectiveReg,
            'customer_number' => $customerNumber,
            'subuser_id' => $subuserId,
            'session_id' => (int)$session->id,
            'source_payload' => $payload,
        ];
        try {
            if ($effectiveReg !== '') {
                $actionSnapshot = $this->buildEligibilitySnapshot(
                    $laneId,
                    $effectiveReg,
                    $customerNumber,
                    $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
                    ['config_source' => 'published']
                );
                if (is_array($actionSnapshot['evaluation_trace']['condition_results'] ?? null)) {
                    $actionContext['condition_results'] = (array)$actionSnapshot['evaluation_trace']['condition_results'];
                }
                if (is_array($actionSnapshot['evaluation_trace']['visibility_condition_results'] ?? null)) {
                    $actionContext['visibility_condition_results'] = (array)$actionSnapshot['evaluation_trace']['visibility_condition_results'];
                }
                $actionContext['allowed_services'] = (array)($actionSnapshot['allowed_services'] ?? []);
                $actionContext['vehicle_type_id'] = $actionSnapshot['vehicle_type_id'] ?? null;
                $actionContext['product'] = $actionSnapshot['vehicle_type_id'] ?? null;
                $actionContext['machine_type_id'] = $actionSnapshot['machine_type']['id'] ?? null;
            }
        } catch (\Throwable) {
            // Action execution should stay best-effort even when preview context cannot be rebuilt.
        }
        (new selfserve_studio_action_runner())->executeForLaneEvent(
            $lane,
            selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
            selfserve_studio_actions::MODE_MACHINE,
            $actionContext
        );
        $this->enableCleanerRelayForStartedWash($lane);

        return $this->getSessionSummary((int)$session->id);
    }

    public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $subuserId = null): bool
    {
        $normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
        $session = $normalizedReg !== null
            ? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
            : $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);

        return $session->exists() && (bool)$session->machine_start_triggered->value();
    }

    protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void
    {
        try {
            if (
                empty($lane->department_lane)
                || empty($lane->department_lane->relay_machine_cleaner_id)
                || trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) === ''
            ) {
                return;
            }
            $lane->setMachineCleanerRelayStatusHard(true);
        } catch (\Throwable) {
            // Best effort only; webhook start flow must continue.
        }
    }

    protected function disableMachineRelayForCompletedWash(int $laneId): void
    {
        try {
            $lane = (new selfserve())->lane($laneId);
            if (empty($lane->department_lane)) {
                return;
            }

            $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
            $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
        } catch (\Throwable) {
            // Best effort only; session completion flow must continue.
        }
    }

    protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
    {
        if (!$this->isRelayConfiguredForLane($lane, $relay)) {
            return;
        }

        try {
            $lane->setRelayStatusHard($relay, false);
        } catch (\Throwable) {
            // Best effort only; session completion flow must continue.
        }
    }

    protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool
    {
        if (empty($lane->department_lane)) {
            return false;
        }

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

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

    public function getSessionSummary(int $sessionId): array
    {
        $session = (new selfserve_wash_sessions_o())->select($sessionId);
        if (!$session->exists()) {
            throw new \RuntimeException('Self-serve wash session not found.');
        }

        $lane = (new department_lanes_o())->select((int)$session->lane_id->value());
        $machineType = null;
        if ($session->machine_type_id->value() !== null) {
            $machineTypeObject = (new selfserve_machine_types_o())->select((int)$session->machine_type_id->value());
            if ($machineTypeObject->exists()) {
                $machineType = $machineTypeObject->asArray();
            }
        }

        $answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId);
        $answers = $this->buildSessionQuestions($session, $answerRows);
        $metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : [];
        $allowedServices = $this->normalizeServiceNames(
            is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
        );
        $machineWashEnabled = $this->isMachineWashEnabled();
        if (!$machineWashEnabled) {
            $allowedServices = $this->withoutMachineService($allowedServices);
        }
        $machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
            ? (bool)$metadata['machine_available']
            : ($lane->exists() && !empty($lane->relay_machine_id->value())));
        $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
            ? (bool)$metadata['all_visible_questions_answered']
            : true;
        if (!array_key_exists('all_visible_questions_answered', $metadata)) {
            foreach ($answers as $answer) {
                if (($answer['answer'] ?? null) === null) {
                    $allVisibleQuestionsAnswered = false;
                    break;
                }
            }
        }

        $tasks = array_map(function (array $row): array {
            return [
                'task_id' => $row['task_id'] === null ? null : (int)$row['task_id'],
                'task' => (string)$row['task_text'],
                'description' => $row['description'] === null ? null : (string)$row['description'],
                'services' => $this->normalizeJsonArray($row['services'] ?? null),
                'buttons' => $this->normalizeJsonArray($row['buttons'] ?? null),
                'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type']
            ];
        }, (new selfserve_wash_session_tasks_o())->listBySession($sessionId));
        if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) {
            $tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices);
        }
        $tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks);

        $events = array_map(function (array $row): array {
            return [
                'id' => (int)$row['id'],
                'type' => (string)$row['event_type'],
                'payload' => $this->normalizeJsonValue($row['payload_json'] ?? null),
                'created_at' => (string)$row['created_at'],
            ];
        }, (new selfserve_wash_session_events_o())->listBySession($sessionId));

        $sessionPayload = $session->asArray();
        $sessionPayload['subuser'] = $this->formatSessionSubuser($this->nullableInt($session->subuser_id->value()));

        return [
            'session' => $sessionPayload,
            'lane' => $lane->exists() ? $lane->asArray() : null,
            'machine_type' => $machineType,
            'subuser' => $sessionPayload['subuser'],
            'questions' => $answers,
            'tasks' => $tasks,
            'events' => $events,
            'allowed_services' => $allowedServices,
            'machine_available' => $machineAvailable,
            'machine_wash_enabled' => $machineWashEnabled,
            'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
            'allowed' => (bool)$session->allowed->value(),
            'config_version_id' => $metadata['config_version_id'] ?? null,
            'evaluation_trace' => $metadata['evaluation_trace'] ?? null,
        ];
    }

    public function getLatestSessionSummary(int $laneId, string $reg): array
    {
        $session = (new selfserve_wash_sessions_o())->selectLatestByLaneAndReg($laneId, selfserve::standardize_registration($reg));
        if (!$session->exists()) {
            throw new \RuntimeException('No self-serve wash session found for the lane and vehicle.');
        }

        return $this->getSessionSummary((int)$session->id);
    }

    public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array
    {
        $session = $reg !== null
            ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
            : $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);

        if (!$session->exists()) {
            return null;
        }
        $resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());

        $this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);

        if (!$session->markCompletedIfOpen($orderId)) {
            return $this->getSessionSummary((int)$session->id);
        }
        if ($disableRelays) {
            $this->disableMachineRelayForCompletedWash($laneId);
        }
        $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
            'lane_id' => $laneId,
            'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
            'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
            'subuser_id' => $resolvedSubuserId,
            'order_id' => $orderId,
        ]);

        return $this->getSessionSummary((int)$session->id);
    }

    protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
    {
        try {
            if ($session->wash_started_at->value() !== null) {
                return;
            }

            $lane = (new selfserve())->lane($laneId);
            $washStartedAt = (int)$lane->getWashStartTime();
            if ($washStartedAt <= 0) {
                return;
            }

            $session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
        } catch (\Throwable) {
            // Session timestamp enrichment must not block STOP completion.
        }
    }

    public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
    {
        $lane = (new selfserve())->lane($laneId);
        $session = $this->resolveForceStopSession($laneId, $sessionId);
        $runtimeSnapshot = $this->buildForceStopRuntimeSnapshot($lane);
        $hasRuntime = $this->laneRuntimeLooksActive($runtimeSnapshot);
        if (!$session->exists() && !$hasRuntime) {
            throw new \RuntimeException('No active self-serve wash session or lane runtime found.');
        }

        $orderId = null;
        if ($bill) {
            try {
                if ($lane->invoice() !== true) {
                    throw new \RuntimeException('Elapsed-minute invoice was not created.');
                }
                $orderId = method_exists($lane, 'getLastInvoiceOrderId') ? $lane->getLastInvoiceOrderId() : null;
            } catch (\Throwable $e) {
                throw new \RuntimeException('Failed to bill elapsed minutes before force stop: ' . $e->getMessage(), 409, $e);
            }
        }

        $summary = null;
        if ($session->exists()) {
            $eventPayload = [
                'lane_id' => $laneId,
                'reason' => $reason,
                'user_id' => $userId,
                'bill' => $bill,
                'order_id' => $orderId,
                'runtime_before_reset' => $runtimeSnapshot,
                'forced_at' => date('Y-m-d H:i:s'),
            ];
            if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
                $summary = $this->getSessionSummary((int)$session->id);
            } else {
                $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
                $summary = $this->getSessionSummary((int)$session->id);
            }
        }

        $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());

        return [
            'lane_id' => $laneId,
            'forced' => true,
            'bill' => $bill,
            'order_id' => $orderId,
            'session' => $summary,
            'runtime_before_reset' => $runtimeSnapshot,
        ];
    }

    /**
     * @param array<string,mixed> $options
     */
    protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
    {
        $normalizedReg = selfserve::standardize_registration($reg);
        $lane = (new department_lanes_o())->select($laneId);
        if (!$lane->exists()) {
            throw new \RuntimeException('Department lane not found.');
        }

        $departmentId = (int)$lane->department->value();
        $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value();
        if (!$lane->isSelfServeEnabled()) {
            $vehicle = $this->findVehicleByRegistration($normalizedReg);
            $vehicleData = $vehicle?->asArray();
            $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
            $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null);

            return [
                'lane' => $lane->asArray(),
                'machine_type' => null,
                'vehicle' => $vehicleData,
                'reg' => $normalizedReg,
                'customer_number' => $resolvedCustomerNumber,
                'subuser_id' => $this->resolveActorSubuserId($options),
                'vehicle_type_id' => $vehicleTypeId,
                'answers' => [],
                'persisted_answers' => [],
                'persisted_answer_customer_number' => null,
                'answer_overrides' => [],
                'answer_sources' => [],
                'questions' => [],
                'conditions' => [],
                'tasks' => [],
                'allowed_services' => [],
                'machine_available' => false,
                'all_visible_questions_answered' => false,
                'allowed' => false,
                'blocked_reason' => 'Self-serve is disabled for this lane.',
                'config_version_id' => null,
                'config_source' => (string)($options['config_source'] ?? 'published'),
                'evaluation_trace' => [
                    'blocked' => true,
                    'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'],
                    'disabled_lane' => true,
                    'message' => 'Self-serve is disabled for this lane.',
                    'visibility_condition_results' => [],
                    'condition_results' => [],
                    'visibility_expression_traces' => [],
                    'condition_expression_traces' => [],
                    'task_gates' => [],
                    'visible_question_ids' => [],
                ],
                'debug_candidates' => [
                    'questions' => [],
                    'conditions' => [],
                    'rules' => [],
                    'tasks' => [],
                    'actions' => [],
                    'visible_answers' => [],
                ],
            ];
        }
        $configSource = (string)($options['config_source'] ?? 'published');
        $publishedConfigVersionId = $options['config_version_id'] ?? null;
        $publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null;
        $versioning = new selfserve_config_versioning();
        if ($publishedConfigPayload === null) {
            $publishedConfig = $versioning->getPublishedV2Config($departmentId);
            $publishedConfigVersionId = $publishedConfig['version_id'] ?? null;
            $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null;
            $configSource = $publishedConfigPayload === null ? 'legacy' : 'published';
        }
        $isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload);
        $vehicle = $this->findVehicleByRegistration($normalizedReg);
        $vehicleData = $vehicle?->asArray();
        $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
        $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null);
        $persistedAnswerCustomerNumber = $this->resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber);

        $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload);
        $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload);
        $rules = $this->loadConditionRules($conditions, $publishedConfigPayload);
        $persistedAnswers = $this->loadPersistedAnswers($departmentId, $laneId, $normalizedReg, $persistedAnswerCustomerNumber);
        $answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []);
        $answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides);
        $answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides);
        if ($isV2Config) {
            $visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers);
            $visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []);
            $visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []);
        } else {
            $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers);
            $visibilityExpressionTrace = [];
        }

        $visibleQuestions = [];
        $visibleQuestionIds = [];
        foreach ($questions as $question) {
            $gateId = $this->nullableInt($question['condition_id'] ?? null);
            if ($gateId !== null && (($visibilityConditionResults[$gateId] ?? false) !== true)) {
                continue;
            }
            $questionId = (int)$question['id'];
            $visibleQuestionIds[] = $questionId;
            $visibleQuestions[] = [
                'id' => $questionId,
                'question' => (string)$question['question'],
                'description' => (string)($question['description'] ?? ''),
                'condition_id' => $gateId,
                'order_priority' => (int)($question['order_priority'] ?? 0),
                'answer' => array_key_exists($questionId, $answers) ? $answers[$questionId] : null,
                'answer_source' => $answerSources[$questionId] ?? 'missing',
            ];
        }

        usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);
        $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds);
        if ($isV2Config) {
            $serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers);
            $serviceConditionResults = (array)($serviceEvaluation['results'] ?? []);
            $serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []);
        } else {
            $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers);
            $serviceExpressionTrace = [];
        }

        $tasks = (new selfserve_task_attachment_payloads())->attachToTasks(
            $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload)
        );
        $activeTasks = [];
        $taskGateTrace = [];
        $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions);
        foreach ($tasks as $task) {
            $gateId = $this->nullableInt($task['condition_id'] ?? null);
            $resolvedGate = $this->resolveTaskGate($task, $conditionIds);
            $typedGateType = $resolvedGate['gate_type'];
            $typedGateRefId = $resolvedGate['gate_ref_id'];

            $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped(
                $typedGateType->value,
                $typedGateRefId,
                $serviceConditionResults,
                $visibleAnswers
            );

            $taskGateTrace[] = [
                'task_id' => (int)$task['id'],
                'legacy_gate_id' => $gateId,
                'gate_type' => $typedGateType->value,
                'gate_ref_id' => $typedGateRefId,
                'satisfied' => $gateSatisfied,
            ];

            if (!$gateSatisfied) {
                continue;
            }

            $activeTasks[] = [
                'id' => (int)$task['id'],
                'task' => (string)$task['task'],
                'description' => (string)($task['description'] ?? ''),
                'condition_id' => $gateId,
                'gate_type' => $typedGateType->value,
                'gate_ref_id' => $typedGateRefId,
                'order_priority' => (int)($task['order_priority'] ?? 0),
                'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
                'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
                'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
                'attachments' => $task['attachments'] ?? [],
            ];
        }
        usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']);

        $allowedServices = [];
        foreach ($activeTasks as $task) {
            foreach ($task['services'] as $service) {
                if (!in_array($service, $allowedServices, true)) {
                    $allowedServices[] = $service;
                }
            }
        }

        $machineWashEnabled = $this->isMachineWashEnabled();
        if (!$machineWashEnabled) {
            $allowedServices = $this->withoutMachineService($allowedServices);
        }

        $machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
        $allVisibleQuestionsAnswered = true;
        foreach ($visibleQuestions as $question) {
            if ($question['answer'] === null) {
                $allVisibleQuestionsAnswered = false;
                break;
            }
        }

        $machineAllowed = $allVisibleQuestionsAnswered
            && $machineAvailable
            && in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true);
        $visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices);

        $machineType = null;
        if ($machineTypeId !== null) {
            $machineTypeObject = (new selfserve_machine_types_o())->select($machineTypeId);
            if ($machineTypeObject->exists()) {
                $machineType = $machineTypeObject->asArray();
            }
        }

        return [
            'lane' => $lane->asArray(),
            'machine_type' => $machineType,
            'vehicle' => $vehicleData,
            'reg' => $normalizedReg,
            'customer_number' => $resolvedCustomerNumber,
            'subuser_id' => $this->resolveActorSubuserId($options),
            'vehicle_type_id' => $vehicleTypeId,
            'answers' => $answers,
            'persisted_answers' => $persistedAnswers,
            'persisted_answer_customer_number' => $persistedAnswerCustomerNumber,
            'answer_overrides' => $answerOverrides,
            'answer_sources' => $answerSources,
            'questions' => $visibleQuestions,
            'conditions' => $serviceConditionResults,
            'tasks' => $visibleTasks,
            'allowed_services' => $allowedServices,
            'machine_available' => $machineAvailable,
            'machine_wash_enabled' => $machineWashEnabled,
            'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
            'allowed' => $machineAllowed,
            'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
            'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
            'config_source' => $configSource,
            'evaluation_trace' => [
                'visibility_condition_results' => $visibilityConditionResults,
                'condition_results' => $serviceConditionResults,
                'visibility_expression_traces' => $visibilityExpressionTrace,
                'condition_expression_traces' => $serviceExpressionTrace,
                'task_gates' => $taskGateTrace,
                'visible_question_ids' => $visibleQuestionIds,
            ],
            'debug_candidates' => [
                'questions' => $questions,
                'conditions' => $conditions,
                'rules' => $rules,
                'tasks' => $tasks,
                'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [],
                'visible_answers' => $visibleAnswers,
            ],
        ];
    }

    protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
    {
        $laneId = (int)$snapshot['lane']['id'];
        $lane = (new selfserve())->lane($laneId);
        $this->enableCleanerRelayForStartedWash($lane);

        if ((bool)$session->machine_relay_enabled->value() === true) {
            return;
        }

        $session->markRelayEnabled();
        $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
            'lane_id' => $laneId,
            'reg' => $snapshot['reg'],
            'allowed_services' => $snapshot['allowed_services'],
            'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
        ]);
    }

    protected function syncMachineRelayFromVisibleServices(array $snapshot, selfserve_wash_sessions_o $session, bool $allowEnable): void
    {
        $laneId = (int)$snapshot['lane']['id'];
        $lane = (new selfserve())->lane($laneId);
        $sync = $lane->syncMachineRelayFromVisibleServices(
            is_array($snapshot['allowed_services'] ?? null) ? $snapshot['allowed_services'] : [],
            $allowEnable
        );

        if (($sync['relay_action'] ?? '') === 'enabled') {
            $this->enableMachineRelayIfAllowed($snapshot, $session);
        }

        $relayTargetOn = (bool)($sync['relay_target_on'] ?? false);
        if (!$relayTargetOn && (bool)$session->machine_relay_enabled->value() === true) {
            $session->markRelayDisabled();
            $session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
        }
    }

    protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status
    {
        if (!$snapshot['all_visible_questions_answered']) {
            return selfserve_wash_session_status::PENDING_QUESTIONS;
        }
        if ($snapshot['allowed']) {
            return selfserve_wash_session_status::READY_FOR_MACHINE_START;
        }
        return selfserve_wash_session_status::MACHINE_NOT_ALLOWED;
    }

    protected function deriveCurrentStatus(array $snapshot, selfserve_wash_sessions_o $session): selfserve_wash_session_status
    {
        if ($session->completed_at->value() !== null) {
            return selfserve_wash_session_status::COMPLETED;
        }
        if ((bool)$session->machine_start_triggered->value() === true) {
            return selfserve_wash_session_status::MACHINE_STARTED;
        }
        if ((bool)$session->machine_relay_enabled->value() === true) {
            return selfserve_wash_session_status::MACHINE_RELAY_ENABLED;
        }
        return $this->deriveBaseStatus($snapshot);
    }

    protected function formatSnapshotResponse(array $snapshot, ?array $session = null): array
    {
        return [
            'lane' => $snapshot['lane'],
            'machine_type' => $snapshot['machine_type'],
            'vehicle' => $snapshot['vehicle'],
            'reg' => $snapshot['reg'],
            'customer_number' => $snapshot['customer_number'],
            'subuser_id' => $snapshot['subuser_id'] ?? null,
            'vehicle_type_id' => $snapshot['vehicle_type_id'],
            'questions' => $snapshot['questions'],
            'tasks' => $snapshot['tasks'],
            'allowed_services' => $snapshot['allowed_services'],
            'machine_available' => $snapshot['machine_available'],
            'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
            'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
            'allowed' => $snapshot['allowed'],
            'blocked_reason' => $snapshot['blocked_reason'] ?? null,
            'session' => $session,
            'config_version_id' => $snapshot['config_version_id'] ?? null,
            'config_source' => $snapshot['config_source'] ?? null,
            'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
        ];
    }

    protected function formatBlockedSessionSummary(array $snapshot): array
    {
        return [
            'session' => null,
            'lane' => $snapshot['lane'],
            'machine_type' => $snapshot['machine_type'],
            'subuser_id' => $snapshot['subuser_id'] ?? null,
            'questions' => [],
            'tasks' => [],
            'events' => [],
            'allowed' => false,
            'allowed_services' => [],
            'machine_available' => false,
            'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.',
            'config_version_id' => $snapshot['config_version_id'] ?? null,
            'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
        ];
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<string,mixed> $options
     * @return array<string,mixed>
     */
    public function buildStudioDebugPayload(int $departmentId, array $snapshot, array $options = []): array
    {
        $lookups = is_array($options['lookups'] ?? null) ? (array)$options['lookups'] : [];
        $candidates = is_array($snapshot['debug_candidates'] ?? null) ? (array)$snapshot['debug_candidates'] : [];
        $questions = $this->buildDebugQuestions($snapshot, (array)($candidates['questions'] ?? []), $lookups);
        $rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups);
        $conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups);
        $tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
        $actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
        $hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
        $dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
        $decisions = $this->buildDebugDecisions(
            $questions,
            $conditions,
            $rules,
            $tasks,
            $actions,
            (array)($hardware['signal_timeline'] ?? []),
            $dynamicImageButtons
        );
        $recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
        $summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
        $stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
        $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? []));

        return [
            'summary' => $summary,
            'parameters' => [
                'department_id' => $departmentId,
                'department' => $this->debugLabel($lookups, 'departments', $departmentId, 'Department ' . $departmentId),
                'lane_id' => (int)($snapshot['lane']['id'] ?? 0),
                'lane' => $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, (string)($snapshot['lane']['name'] ?? 'Lane')),
                'machine_type_id' => $snapshot['machine_type']['id'] ?? null,
                'machine_type' => $this->debugLabel($lookups, 'machine_types', $snapshot['machine_type']['id'] ?? null, 'No machine type'),
                'vehicle_type_id' => $snapshot['vehicle_type_id'],
                'vehicle_type' => $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'Auto'),
                'registration' => $snapshot['reg'],
                'customer_number' => $snapshot['customer_number'],
                'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'),
                'config_version_id' => $snapshot['config_version_id'] ?? null,
                'hardware_mode' => $options['hardware_mode'] ?? 'studio',
                'mode' => 'full_dry_run',
                'dry_run' => true,
            ],
            'stages' => $stages,
            'questions' => $questions,
            'conditions' => $conditions,
            'rules' => $rules,
            'tasks' => $tasks,
            'actions' => $actions,
            'dynamic_image_buttons' => $dynamicImageButtons,
            'decisions' => $decisions,
            'hardware' => $hardware,
            'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
            'graph_annotations' => $annotations,
            'recommendations' => $recommendations,
        ];
    }

    /**
     * @param mixed $raw
     * @return array<int,bool|null>
     */
    protected function normalizeAnswerOverrides(mixed $raw): array
    {
        $overrides = [];
        if (!is_array($raw)) {
            return $overrides;
        }

        foreach ($raw as $key => $entry) {
            if (is_array($entry)) {
                $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key);
                $value = $entry['value'] ?? $entry['answer'] ?? null;
            } else {
                $questionId = (int)$key;
                $value = $entry;
            }
            if ($questionId <= 0) {
                continue;
            }
            if ($value === null || $value === '' || strtolower((string)$value) === 'unset' || strtolower((string)$value) === 'null') {
                $overrides[$questionId] = null;
                continue;
            }
            $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            $overrides[$questionId] = $parsed;
        }

        return $overrides;
    }

    /**
     * @param array<int,bool|null> $answers
     * @param array<int,bool|null> $overrides
     * @return array<int,bool|null>
     */
    protected function applyAnswerOverrides(array $answers, array $overrides): array
    {
        foreach ($overrides as $questionId => $value) {
            $answers[(int)$questionId] = $value;
        }
        return $answers;
    }

    /**
     * @param array<int,bool|null> $answers
     * @param array<int,bool|null> $overrides
     * @return array<int,string>
     */
    protected function buildAnswerSources(array $answers, array $overrides): array
    {
        $sources = [];
        foreach ($answers as $questionId => $_value) {
            $sources[(int)$questionId] = 'saved';
        }
        foreach ($overrides as $questionId => $_value) {
            $sources[(int)$questionId] = 'override';
        }
        return $sources;
    }

    protected function resolvePersistedAnswerCustomerNumber(?int $resolvedCustomerNumber): ?int
    {
        if ($resolvedCustomerNumber === null || $resolvedCustomerNumber <= 0) {
            return null;
        }

        return $resolvedCustomerNumber;
    }

    /**
     * @return array<int,bool>
     */
    protected function loadPersistedAnswers(int $departmentId, int $laneId, string $reg, ?int $customerNumber): array
    {
        if ($customerNumber === null || $customerNumber <= 0) {
            return [];
        }

        return (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle(
            $departmentId,
            $laneId,
            $reg,
            $customerNumber
        );
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $questions
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugQuestions(array $snapshot, array $questions, array $lookups): array
    {
        $visibleIds = array_flip(array_map('intval', (array)($snapshot['evaluation_trace']['visible_question_ids'] ?? [])));
        $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : [];
        $sources = is_array($snapshot['answer_sources'] ?? null) ? (array)$snapshot['answer_sources'] : [];
        $conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
            ? (array)$snapshot['evaluation_trace']['visibility_condition_results']
            : [];
        $expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
            ? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
            : [];
        $items = [];

        foreach ($questions as $question) {
            $questionId = (int)($question['id'] ?? 0);
            if ($questionId <= 0) {
                continue;
            }
            $gateId = $this->nullableInt($question['condition_id'] ?? null);
            $visible = isset($visibleIds[$questionId]);
            $answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
            $state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
            $label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
            $conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
            $gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
            $causes = [];
            if ($gateId !== null) {
                $causes[] = $this->debugCause(
                    'condition',
                    $gateId,
                    $conditionLabel,
                    true,
                    ($conditionResults[$gateId] ?? null),
                    $this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
                );
            }
            if ($visible && $answer === null) {
                $causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
            }
            $reason = $visible
                ? ($answer === null
                    ? 'Question ' . $label . ' is visible but has no answer.'
                    : 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
                : 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
            $items[] = [
                'kind' => 'question',
                'id' => $questionId,
                'node_id' => 'question:' . $questionId,
                'node_ids' => ['question:' . $questionId],
                'label' => $label,
                'visible' => $visible,
                'state' => $state,
                'answer' => $answer,
                'answer_source' => $sources[$questionId] ?? 'missing',
                'condition_id' => $gateId,
                'condition' => $conditionLabel,
                'gate_satisfied' => $gateSatisfied,
                'reason' => $reason,
                'causes' => $causes,
                'order_priority' => (int)($question['order_priority'] ?? 0),
            ];
        }

        usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id']));
        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $rules
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugRules(array $snapshot, array $rules, array $lookups): array
    {
        $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : [];
        $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : [];
        $items = [];

        foreach ($rules as $rule) {
            $ruleId = (int)($rule['id'] ?? 0);
            if ($ruleId <= 0) {
                continue;
            }
            $objectType = strtolower((string)($rule['object_type'] ?? ''));
            $objectId = (int)($rule['object_id'] ?? 0);
            $actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
            $satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
            $lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
            $label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
            $objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
            $expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
            $invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
            $reason = $invalidReference
                ? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
                : ($satisfied
                    ? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
                    : 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
            $items[] = [
                'kind' => 'rule',
                'id' => $ruleId,
                'node_id' => 'rule:' . $ruleId,
                'node_ids' => ['rule:' . $ruleId],
                'condition_id' => (int)($rule['condition_id'] ?? 0),
                'label' => $label,
                'type' => (string)($rule['type'] ?? ''),
                'object_type' => $objectType,
                'object_id' => $objectId,
                'object_label' => $objectLabel,
                'actual_value' => $actual,
                'satisfied' => $satisfied,
                'invalid_reference' => $invalidReference,
                'reason' => $reason,
                'causes' => [
                    $this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
                ],
            ];
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array
    {
        $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : [];
        $expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : [];
        $cycleIds = $this->detectConditionCycles($conditions, $rules);
        $rulesByCondition = [];
        foreach ($rules as $rule) {
            $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule;
        }

        $items = [];
        foreach ($conditions as $condition) {
            $conditionId = (int)($condition['id'] ?? 0);
            if ($conditionId <= 0) {
                continue;
            }
            $result = ($conditionResults[$conditionId] ?? false) === true;
            $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
            $expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
            $nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
            $label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
            $causes = [];
            $failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
            if ($failedExpressionCause !== null) {
                $causes[] = $failedExpressionCause;
            } elseif (!$result) {
                foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
                    if (($rule['satisfied'] ?? false) !== true) {
                        foreach ((array)($rule['causes'] ?? []) as $cause) {
                            if (is_array($cause)) {
                                $causes[] = $cause;
                            }
                        }
                        break;
                    }
                }
            }
            $items[] = [
                'kind' => 'condition',
                'id' => $conditionId,
                'node_id' => 'condition:' . $conditionId,
                'node_ids' => ['condition:' . $conditionId],
                'label' => $label,
                'result' => $result,
                'state' => $result ? 'passed' : 'failed',
                'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
                'rules' => array_values($rulesByCondition[$conditionId] ?? []),
                'expression' => $expression,
                'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups),
                'expression_trace' => $expressionTrace,
                'next_fix' => $nextFix,
                'has_cycle' => in_array($conditionId, $cycleIds, true),
                'reason' => in_array($conditionId, $cycleIds, true)
                    ? 'Condition dependency cycle detected.'
                    : ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
                'causes' => $causes,
            ];
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $tasks
     * @param array<string,mixed> $lookups
     * @param array<string,mixed> $gatewayWorkspace
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugTasks(array $snapshot, array $tasks, array $lookups, array $gatewayWorkspace): array
    {
        $activeIds = array_flip(array_map(static fn(array $task): int => (int)($task['id'] ?? 0), (array)($snapshot['tasks'] ?? [])));
        $activeAttachments = [];
        foreach ((array)($snapshot['tasks'] ?? []) as $task) {
            if (!is_array($task)) {
                continue;
            }
            $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0);
            if ($taskId > 0 && is_array($task['attachments'] ?? null)) {
                $activeAttachments[$taskId] = array_values($task['attachments']);
            }
        }
        $gateTrace = [];
        foreach ((array)($snapshot['evaluation_trace']['task_gates'] ?? []) as $trace) {
            if (is_array($trace)) {
                $gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
            }
        }
        $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
            ? (array)$snapshot['evaluation_trace']['condition_results']
            : [];
        $visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
            ? (array)$snapshot['debug_candidates']['visible_answers']
            : (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
        $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
        $items = [];

        foreach ($tasks as $task) {
            $taskId = (int)($task['id'] ?? 0);
            if ($taskId <= 0) {
                continue;
            }
            $trace = $gateTrace[$taskId] ?? [];
            $services = $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null));
            $bindings = [];
            foreach ($services as $service) {
                foreach ($bindingsByService[$service] ?? [] as $binding) {
                    $bindings[] = $binding;
                }
            }
            $active = isset($activeIds[$taskId]);
            $gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
            $gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
            $gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
            $label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
            $gateSatisfied = ($trace['satisfied'] ?? false) === true;
            $gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
            $taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
            $items[] = [
                'kind' => 'task',
                'id' => $taskId,
                'node_id' => 'task:' . $taskId,
                'node_ids' => ['task:' . $taskId],
                'label' => $label,
                'description' => (string)($task['description'] ?? ''),
                'active' => $active,
                'state' => $active ? 'active' : 'blocked',
                'gate_type' => $gateType,
                'gate_ref_id' => $gateRefId,
                'gate_ref_label' => $gateRefLabel,
                'gate_satisfied' => $gateSatisfied,
                'services' => $services,
                'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
                'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
                'attachments' => $taskAttachments,
                'relay_bindings' => $bindings,
                'order_priority' => (int)($task['order_priority'] ?? 0),
                'reason' => $gateDecision['reason'],
                'causes' => $gateDecision['causes'],
            ];
        }

        usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id']));
        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $actions
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array
    {
        $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
            ? (array)$snapshot['evaluation_trace']['condition_results']
            : [];
        $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : [];
        $laneId = (int)($lane['id'] ?? 0);
        $departmentId = (int)($lane['department'] ?? 0);
        $vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null);
        $machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null);
        $eventModes = [
            selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot),
            selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot),
            selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE,
        ];
        $items = [];

        foreach ($actions as $row) {
            if (!is_array($row)) {
                continue;
            }
            $action = selfserve_studio_actions::normalize((array)$row);
            $actionId = (int)($action['id'] ?? 0);
            if ($actionId <= 0) {
                continue;
            }

            $expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH;
            $modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
            $scopeMatches = true;
            $scopeReason = null;
            $scopeCause = null;
            if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
                $scopeMatches = false;
                $scopeReason = 'Action department scope does not match the simulated lane.';
                $scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
            } elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
                $scopeMatches = false;
                $scopeReason = 'Action lane scope does not match the simulated lane.';
                $scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
            } elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
                $scopeMatches = false;
                $scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
                $scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
            } elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
                $scopeMatches = false;
                $scopeReason = 'Action machine type scope does not match the simulated lane.';
                $scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
            }

            $conditionId = $this->nullableInt($action['condition_id'] ?? null);
            $conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
            $enabled = (bool)($action['enabled'] ?? true);
            $active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
            $label = (string)$action['name'];
            $conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
            $causes = [];
            $reason = 'Action would run for this simulator event.';
            if (!$enabled) {
                $reason = 'Action is disabled.';
                $causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
            } elseif (!$modeMatches) {
                $reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
                $causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
            } elseif (!$scopeMatches) {
                $reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
                if ($scopeCause !== null) {
                    $causes[] = $scopeCause;
                }
            } elseif (!$conditionSatisfied) {
                $reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
                $causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
            }

            $items[] = [
                'kind' => 'action',
                'id' => $actionId,
                'node_id' => 'action:' . $actionId,
                'node_ids' => ['action:' . $actionId],
                'label' => $label,
                'active' => $active,
                'state' => $active ? 'active' : 'skipped',
                'reason' => $reason,
                'causes' => $causes,
                'event' => (string)$action['event'],
                'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
                'wash_mode' => (string)$action['wash_mode'],
                'simulated_wash_mode' => $expectedMode,
                'operation' => (string)$action['operation'],
                'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']),
                'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
                'relay_state' => $action['relay_state'],
                'condition_id' => $conditionId,
                'condition' => $conditionLabel,
                'condition_satisfied' => $conditionSatisfied,
                'scope' => [
                    'department' => (int)$action['department'],
                    'lane' => (int)$action['lane'],
                    'product' => (int)$action['product'],
                    'machine_type_id' => $action['machine_type_id'],
                ],
                'options' => (array)$action['options'],
                'order_priority' => (int)$action['order_priority'],
                'raw' => $action,
            ];
        }

        usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event'])
            ?: ((int)$a['order_priority'] <=> (int)$b['order_priority'])
            ?: ((int)$a['id'] <=> (int)$b['id']));
        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $tasks
     * @param array<string,mixed> $gatewayWorkspace
     * @param array<string,mixed> $lookups
     * @param array<int,array<string,mixed>> $actions
     * @return array<string,mixed>
     */
    protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array
    {
        $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
        $allowedServices = (array)($snapshot['allowed_services'] ?? []);
        $missingBindings = [];
        foreach ($allowedServices as $service) {
            $service = strtoupper((string)$service);
            if ($service !== '' && empty($bindingsByService[$service])) {
                $missingBindings[] = $service;
            }
        }

        $laneId = (int)($snapshot['lane']['id'] ?? 0);
        $laneRelaySlots = [];
        foreach ((array)($gatewayWorkspace['lanes'] ?? []) as $lane) {
            if (!is_array($lane) || (int)($lane['id'] ?? 0) !== $laneId) {
                continue;
            }
            foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
                if (is_array($slot)) {
                    $laneRelaySlots[] = $slot;
                }
            }
        }
        $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions);

        $dryRunOperations = [];
        if (($snapshot['allowed'] ?? false) === true) {
            $dryRunOperations[] = [
                'operation' => 'machine_relay_enable',
                'status' => 'predicted',
                'message' => 'Dry run predicts the machine relay would be enabled.',
            ];
        } else {
            $dryRunOperations[] = [
                'operation' => 'machine_relay_enable',
                'status' => 'blocked',
                'message' => 'Dry run predicts no machine relay action because eligibility is blocked.',
            ];
        }

        return [
            'machine_relay_configured' => (bool)($snapshot['machine_available'] ?? false),
            'lane_relay_slots' => $laneRelaySlots,
            'allowed_services' => $allowedServices,
            'service_bindings' => $bindingsByService,
            'missing_service_bindings' => array_values($missingBindings),
            'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])),
            'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])),
            'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false),
            'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [],
            'signal_timeline' => $signalTimeline,
            'dry_run_operations' => $dryRunOperations,
            'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups),
        ];
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<string,mixed> $gatewayWorkspace
     * @param array<string,array<int,array<string,mixed>>> $bindingsByService
     * @param array<int,array<string,mixed>> $laneRelaySlots
     * @param array<int,array<string,mixed>> $actions
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array
    {
        $timeline = [];
        $sequence = 1;
        $allowed = (bool)($snapshot['allowed'] ?? false);
        $machineAvailable = (bool)($snapshot['machine_available'] ?? false);

        $timeline[] = $this->debugSignalTimelineRow(
            $sequence++,
            'eligibility_sync',
            'session_event',
            'SESSION',
            null,
            null,
            [
                'event' => 'SESSION_SYNCED',
                'allowed' => $allowed,
                'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])),
            ],
            'none',
            'sent',
            null
        );

        $this->appendDebugActionSignalRows(
            $timeline,
            $sequence,
            $actions,
            selfserve_studio_actions::EVENT_WASH_START_COMMAND,
            $allowed,
            $gatewayWorkspace,
            $bindingsByService,
            $laneRelaySlots,
            $snapshot
        );

        $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot);
        $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE');
        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'eligibility_sync',
            'relay_switch',
            'MACHINE',
            $machineRelayId,
            $machineBinding,
            ['id' => $machineRelayId, 'channel' => 0, 'on' => true],
            $allowed && $machineAvailable,
            $allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.'
        );

        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'machine_start_signal',
            'shelly_event',
            'MACHINE',
            $machineRelayId,
            $machineBinding,
            [
                'event' => 'input.toggle_on',
                'alternate_event' => 'switch.on',
                'input' => ['component' => 'input:0', 'state' => true],
                'switch' => ['component' => 'switch:0', 'output' => true],
                'bill_machine_wash' => true,
            ],
            $allowed && $machineAvailable,
            $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.'
        );

        $this->appendDebugActionSignalRows(
            $timeline,
            $sequence,
            $actions,
            selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
            $allowed && $machineAvailable,
            $gatewayWorkspace,
            $bindingsByService,
            $laneRelaySlots,
            $snapshot
        );

        $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot);
        $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER');
        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'machine_start',
            'relay_switch',
            'CLEANER',
            $cleanerRelayId,
            $cleanerBinding,
            ['id' => $cleanerRelayId, 'channel' => 0, 'on' => true],
            $allowed && $cleanerRelayId !== null,
            $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.'
        );

        $this->appendDebugActionSignalRows(
            $timeline,
            $sequence,
            $actions,
            selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
            $allowed,
            $gatewayWorkspace,
            $bindingsByService,
            $laneRelaySlots,
            $snapshot
        );

        $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot);
        $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT');
        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'stop',
            'relay_pulse',
            'EXIT',
            $exitRelayId,
            $exitBinding,
            ['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1],
            $allowed && $exitRelayId !== null,
            $allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.'
        );

        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'stop',
            'relay_switch',
            'CLEANER',
            $cleanerRelayId,
            $cleanerBinding,
            ['id' => $cleanerRelayId, 'channel' => 0, 'on' => false],
            $allowed && $cleanerRelayId !== null,
            $allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.'
        );

        $timeline[] = $this->debugRelaySignalTimelineRow(
            $sequence++,
            'stop',
            'relay_switch',
            'MACHINE',
            $machineRelayId,
            $machineBinding,
            ['id' => $machineRelayId, 'channel' => 0, 'on' => false],
            $machineRelayId !== null,
            $machineRelayId === null ? 'Lane machine relay is not configured.' : null
        );

        $timeline[] = $this->debugSignalTimelineRow(
            $sequence,
            'session_completion',
            'session_event',
            'SESSION',
            null,
            null,
            [
                'event' => 'SESSION_COMPLETED',
                'reset_lane_state' => true,
            ],
            'none',
            $allowed ? 'sent' : 'skipped',
            $allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.'
        );

        return $timeline;
    }

    protected function debugWashModeForStart(array $snapshot): string
    {
        return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)
            ? selfserve_studio_actions::MODE_MACHINE
            : selfserve_studio_actions::MODE_MANUAL;
    }

    protected function debugWashModeForStop(array $snapshot): string
    {
        return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true)
            ? selfserve_studio_actions::MODE_MACHINE
            : selfserve_studio_actions::MODE_MANUAL;
    }

    /**
     * @param array<int,array<string,mixed>> $timeline
     * @param array<int,array<string,mixed>> $actions
     * @param array<string,mixed> $gatewayWorkspace
     * @param array<string,array<int,array<string,mixed>>> $bindingsByService
     * @param array<int,array<string,mixed>> $laneRelaySlots
     * @param array<string,mixed> $snapshot
     */
    protected function appendDebugActionSignalRows(
        array &$timeline,
        int &$sequence,
        array $actions,
        string $event,
        bool $eventAllowed,
        array $gatewayWorkspace,
        array $bindingsByService,
        array $laneRelaySlots,
        array $snapshot
    ): void {
        $eventActions = array_values(array_filter(
            $actions,
            static fn(array $action): bool => (string)($action['event'] ?? '') === $event
        ));
        usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0))
            ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));

        foreach ($eventActions as $action) {
            $raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action);
            $operation = (string)($raw['operation'] ?? '');
            $relayRole = selfserve_studio_actions::relayRoleForOperation($operation);
            $runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event);
            $isRelayOperation = selfserve_studio_actions::isRelayOperation($operation);
            $isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true);
            $relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot);
            $binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole);
            $active = (bool)($action['active'] ?? false);
            $reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.');
            $payload = [
                'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0),
                'action' => (string)($raw['name'] ?? $action['label'] ?? ''),
                'event' => $event,
                'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH),
                'operation' => $operation,
                'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null),
                'enabled' => (bool)($raw['enabled'] ?? true),
                'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0),
                'options' => (array)($raw['options'] ?? []),
            ];

            if ($isRelayOperation) {
                $payload['id'] = $relayId;
                $payload['channel'] = 0;
                $payload['on'] = (bool)($raw['relay_state'] ?? true);
                $signalType = 'studio_action_relay_switch';
            } elseif ($isPropertyGate) {
                $payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE';
                $signalType = 'studio_action_gate_open';
            } else {
                $payload['id'] = $relayId;
                $payload['on'] = true;
                $payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1;
                $signalType = 'studio_action_relay_pulse';
            }

            if (!$active) {
                $timeline[] = $this->debugSignalTimelineRow(
                    $sequence++,
                    $runtimeStage,
                    $signalType,
                    $relayRole,
                    $relayId,
                    $binding,
                    $payload,
                    $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
                    'skipped',
                    $reason
                );
                continue;
            }

            if (!$eventAllowed) {
                $timeline[] = $this->debugSignalTimelineRow(
                    $sequence++,
                    $runtimeStage,
                    $signalType,
                    $relayRole,
                    $relayId,
                    $binding,
                    $payload,
                    $binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
                    'blocked',
                    'Action event would not fire because the dry-run scenario is blocked before this stage.'
                );
                continue;
            }

            if ($isPropertyGate) {
                $timeline[] = $this->debugSignalTimelineRow(
                    $sequence++,
                    $runtimeStage,
                    $signalType,
                    $relayRole,
                    null,
                    null,
                    $payload,
                    'real',
                    'sent',
                    null
                );
                continue;
            }

            $timeline[] = $this->debugRelaySignalTimelineRow(
                $sequence++,
                $runtimeStage,
                $signalType,
                $relayRole,
                $relayId,
                $binding,
                $payload,
                true,
                'Action relay is not configured for the simulated lane.'
            );
        }
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $laneRelaySlots
     */
    protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string
    {
        $role = strtoupper(trim($role));
        foreach ($laneRelaySlots as $slot) {
            if (!is_array($slot)) {
                continue;
            }
            $slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '')));
            $relayId = trim((string)($slot['relay_id'] ?? ''));
            if ($slotRole === $role && $relayId !== '') {
                return $relayId;
            }
        }

        $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : [];
        $field = match ($role) {
            'ENTRY' => 'relay_in_id',
            'EXIT' => 'relay_out_id',
            'MACHINE' => 'relay_machine_id',
            'PROGRAM_PICKER' => 'relay_machine_program_picker_id',
            'CLEANER' => 'relay_machine_cleaner_id',
            default => '',
        };
        $relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : '';
        return $relayId !== '' ? $relayId : null;
    }

    /**
     * @param array<string,mixed> $gatewayWorkspace
     * @param array<string,array<int,array<string,mixed>>> $bindingsByService
     * @return array<string,mixed>|null
     */
    protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array
    {
        if ($relayId === null || trim($relayId) === '') {
            return null;
        }
        $role = strtoupper(trim($role));

        foreach ((array)($bindingsByService[$role] ?? []) as $binding) {
            if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) {
                return $binding;
            }
        }

        foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) {
            if ((string)($binding['relay_id'] ?? '') === $relayId) {
                return $binding;
            }
        }

        return null;
    }

    /**
     * @param array<string,mixed> $binding|null
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    protected function debugRelaySignalTimelineRow(
        int $sequence,
        string $runtimeStage,
        string $signalType,
        string $relayRole,
        ?string $relayId,
        ?array $binding,
        array $payload,
        bool $eligible,
        ?string $reason
    ): array {
        if ($relayId === null || trim($relayId) === '') {
            return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason);
        }
        if (!$eligible) {
            return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason);
        }
        if ($binding === null) {
            return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.');
        }

        $source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real';
        return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null);
    }

    /**
     * @param array<string,mixed>|null $binding
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    protected function debugSignalTimelineRow(
        int $sequence,
        string $runtimeStage,
        string $signalType,
        string $relayRole,
        ?string $relayId,
        ?array $binding,
        array $payload,
        string $source,
        string $predictedStatus,
        ?string $reason
    ): array {
        $id = 'signal:' . $sequence;
        $label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
        $targetBinding = $binding['node_id'] ?? null;
        $nodeIds = [$id];
        if (isset($payload['action_id'])) {
            $nodeIds[] = 'action:' . (int)$payload['action_id'];
        }
        if (is_string($targetBinding) && $targetBinding !== '') {
            $nodeIds[] = $targetBinding;
        }
        if ($relayId !== null && trim($relayId) !== '') {
            $nodeIds[] = 'relay:' . $relayId;
        }
        $causes = $reason === null ? [] : [
            $this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
        ];

        return [
            'kind' => 'signal',
            'id' => $id,
            'node_id' => $id,
            'node_ids' => array_values(array_unique($nodeIds)),
            'label' => $label,
            'state' => $predictedStatus,
            'sequence' => $sequence,
            'runtime_stage' => $runtimeStage,
            'signal_type' => $signalType,
            'relay_role' => $relayRole,
            'relay_id' => $relayId,
            'target_gateway' => $binding['gateway_id'] ?? null,
            'target_gateway_label' => $binding['gateway_label'] ?? null,
            'target_binding' => $binding['node_id'] ?? null,
            'target_binding_label' => $binding['relay_label'] ?? null,
            'transport' => match ($signalType) {
                'session_event' => 'selfserve_wash_session_events',
                'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event',
                default => '/v2/devices/api/set/switch',
            },
            'payload' => $payload,
            'source' => $source,
            'virtual' => $source === 'virtual',
            'predicted_status' => $predictedStatus,
            'skip_block_reason' => $reason,
            'reason' => $reason,
            'causes' => $causes,
        ];
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $questions
     * @param array<int,array<string,mixed>> $tasks
     * @param array<string,mixed> $hardware
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugRecommendations(array $snapshot, array $questions, array $tasks, array $hardware, array $conditions, array $rules): array
    {
        $items = [];
        $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing'));
        if ($missingQuestions !== []) {
            $items[] = [
                'severity' => 'error',
                'title' => 'Answer required questions',
                'message' => count($missingQuestions) . ' visible question(s) are missing answers.',
                'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $missingQuestions),
            ];
        }
        if (($snapshot['machine_available'] ?? false) !== true) {
            $items[] = [
                'severity' => 'error',
                'title' => 'Configure lane machine relay',
                'message' => 'The selected lane has no machine relay configured, so the machine cannot start.',
                'node_ids' => ['lane:' . (int)($snapshot['lane']['id'] ?? 0)],
            ];
        }
        if (!in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)) {
            $items[] = [
                'severity' => 'error',
                'title' => 'Expose MACHINE service',
                'message' => 'No active task exposes the MACHINE service for this scenario.',
                'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks),
            ];
        }
        foreach ((array)($hardware['missing_service_bindings'] ?? []) as $service) {
            $items[] = [
                'severity' => 'warning',
                'title' => 'Bind gateway relay for ' . $service,
                'message' => 'The active service has no edge gateway relay binding in the studio hardware workspace.',
                'node_ids' => [],
            ];
        }
        foreach ($conditions as $condition) {
            if (($condition['has_cycle'] ?? false) === true) {
                $items[] = [
                    'severity' => 'error',
                    'title' => 'Fix condition cycle',
                    'message' => 'Condition "' . (string)$condition['label'] . '" depends on itself through another condition.',
                    'node_ids' => [(string)$condition['node_id']],
                ];
            }
        }
        foreach ($rules as $rule) {
            if (($rule['invalid_reference'] ?? false) === true) {
                $items[] = [
                    'severity' => 'error',
                    'title' => 'Fix invalid rule reference',
                    'message' => 'Rule "' . (string)$rule['label'] . '" references an invalid object.',
                    'node_ids' => [(string)$rule['node_id']],
                ];
            }
        }

        if ($items === []) {
            $items[] = [
                'severity' => 'success',
                'title' => 'Flow is ready',
                'message' => 'The simulated parameters pass every dry-run check.',
                'node_ids' => ['checkpoint:eligible'],
            ];
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $recommendations
     * @param array<string,mixed> $hardware
     * @return array<string,mixed>
     */
    protected function buildDebugSummary(array $snapshot, array $recommendations, array $hardware): array
    {
        $primary = null;
        foreach ($recommendations as $recommendation) {
            if (($recommendation['severity'] ?? '') === 'error') {
                $primary = $recommendation;
                break;
            }
        }
        $warnings = array_values(array_filter($recommendations, static fn(array $recommendation): bool => ($recommendation['severity'] ?? '') === 'warning'));
        $allowed = ($snapshot['allowed'] ?? false) === true;

        return [
            'status' => $allowed ? ($warnings === [] ? 'allowed' : 'warning') : 'blocked',
            'allowed' => $allowed,
            'title' => $allowed ? ($warnings === [] ? 'Allowed' : 'Allowed with warnings') : 'Blocked',
            'primary_blocker' => $primary,
            'next_action' => $primary['message'] ?? ($warnings[0]['message'] ?? 'No action required.'),
            'warning_count' => count($warnings),
            'dry_run' => true,
            'hardware_ready' => (bool)($hardware['machine_relay_configured'] ?? false),
        ];
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $questions
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @param array<int,array<string,mixed>> $tasks
     * @param array<string,mixed> $hardware
     * @param array<string,mixed> $summary
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugStages(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $summary, array $lookups): array
    {
        $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing'));
        $activeTasks = array_values(array_filter($tasks, static fn(array $task): bool => ($task['active'] ?? false) === true));
        $failedConditions = array_values(array_filter($conditions, static fn(array $condition): bool => ($condition['result'] ?? false) !== true));

        return [
            [
                'id' => 'input',
                'title' => 'Input normalization',
                'status' => 'ok',
                'summary' => 'Registration normalized to ' . (string)$snapshot['reg'] . '.',
                'node_ids' => ['checkpoint:start'],
                'edge_ids' => ['runtime:start-eligible'],
            ],
            [
                'id' => 'scope',
                'title' => 'Lane and scope resolution',
                'status' => $snapshot['vehicle_type_id'] === null ? 'warning' : 'ok',
                'summary' => 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' uses ' . $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'automatic vehicle type') . '.',
                'node_ids' => array_values(array_filter([
                    'lane:' . (int)($snapshot['lane']['id'] ?? 0),
                    $snapshot['vehicle_type_id'] === null ? null : 'vehicle_type:' . (int)$snapshot['vehicle_type_id'],
                    isset($snapshot['machine_type']['id']) ? 'machine_type:' . (int)$snapshot['machine_type']['id'] : null,
                ])),
                'edge_ids' => [],
            ],
            [
                'id' => 'questions',
                'title' => 'Question visibility and answers',
                'status' => $missingQuestions === [] ? 'ok' : 'error',
                'summary' => count($questions) . ' question(s) evaluated; ' . count($missingQuestions) . ' visible question(s) missing answers.',
                'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $questions),
                'edge_ids' => [],
            ],
            [
                'id' => 'conditions',
                'title' => 'Condition and rule evaluation',
                'status' => count(array_filter($conditions, static fn(array $condition): bool => ($condition['has_cycle'] ?? false) === true)) > 0 ? 'error' : 'ok',
                'summary' => count($conditions) . ' condition(s) and ' . count($rules) . ' rule(s) evaluated; ' . count($failedConditions) . ' condition(s) false.',
                'node_ids' => array_merge(
                    array_map(static fn(array $condition): string => (string)$condition['node_id'], $conditions),
                    array_map(static fn(array $rule): string => (string)$rule['node_id'], $rules),
                ),
                'edge_ids' => [],
            ],
            [
                'id' => 'tasks',
                'title' => 'Task gates and services',
                'status' => in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) ? 'ok' : 'error',
                'summary' => count($activeTasks) . ' task(s) active; services: ' . implode(', ', (array)($snapshot['allowed_services'] ?? [])),
                'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks),
                'edge_ids' => [],
            ],
            [
                'id' => 'hardware',
                'title' => 'Gateway and relay readiness',
                'status' => ($snapshot['machine_available'] ?? false) === true ? (((array)($hardware['missing_service_bindings'] ?? [])) === [] ? 'ok' : 'warning') : 'error',
                'summary' => (string)($hardware['summary'] ?? ''),
                'node_ids' => [],
                'edge_ids' => [],
            ],
            [
                'id' => 'final',
                'title' => 'Final eligibility decision',
                'status' => ($summary['status'] ?? '') === 'blocked' ? 'error' : (($summary['status'] ?? '') === 'warning' ? 'warning' : 'ok'),
                'summary' => (string)($summary['next_action'] ?? ''),
                'node_ids' => ['checkpoint:eligible', 'checkpoint:finish'],
                'edge_ids' => ['runtime:eligible-finish'],
            ],
        ];
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,array<string,mixed>> $questions
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @param array<int,array<string,mixed>> $tasks
     * @param array<int,array<string,mixed>> $actions
     * @param array<string,mixed> $hardware
     * @param array<string,mixed> $graph
     * @return array<string,mixed>
     */
    protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array
    {
        $nodes = [
            'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'],
            'checkpoint:eligible' => ['state' => ($snapshot['allowed'] ?? false) ? 'active' : 'blocked', 'label' => 'Eligibility resolved'],
            'checkpoint:finish' => ['state' => ($snapshot['allowed'] ?? false) ? 'visited' : 'not_applicable', 'label' => 'Predicted completion checkpoint'],
            'lane:' . (int)($snapshot['lane']['id'] ?? 0) => ['state' => 'active', 'label' => 'Selected lane'],
        ];
        if ($snapshot['vehicle_type_id'] !== null) {
            $nodes['vehicle_type:' . (int)$snapshot['vehicle_type_id']] = ['state' => 'active', 'label' => 'Selected vehicle type'];
        }
        if (isset($snapshot['machine_type']['id'])) {
            $nodes['machine_type:' . (int)$snapshot['machine_type']['id']] = ['state' => 'active', 'label' => 'Resolved machine type'];
        }

        foreach ($questions as $question) {
            $state = match ($question['state'] ?? '') {
                'answered' => 'active',
                'missing' => 'warning',
                default => 'not_applicable',
            };
            $nodes[(string)$question['node_id']] = ['state' => $state, 'label' => (string)$question['reason']];
        }
        foreach ($conditions as $condition) {
            $nodes[(string)$condition['node_id']] = [
                'state' => ($condition['has_cycle'] ?? false) ? 'error' : (($condition['result'] ?? false) ? 'active' : 'blocked'),
                'label' => (string)$condition['reason'],
            ];
        }
        foreach ($rules as $rule) {
            $nodes[(string)$rule['node_id']] = [
                'state' => ($rule['invalid_reference'] ?? false) ? 'error' : (($rule['satisfied'] ?? false) ? 'active' : 'blocked'),
                'label' => (string)$rule['reason'],
            ];
        }
        foreach ($tasks as $task) {
            $nodes[(string)$task['node_id']] = [
                'state' => ($task['active'] ?? false) ? 'active' : 'blocked',
                'label' => (string)$task['reason'],
            ];
            foreach ((array)($task['relay_bindings'] ?? []) as $binding) {
                if (isset($binding['node_id'])) {
                    $nodes[(string)$binding['node_id']] = ['state' => ($task['active'] ?? false) ? 'active' : 'visited', 'label' => 'Relay binding for active service'];
                }
                if (isset($binding['gateway_node_id'])) {
                    $nodes[(string)$binding['gateway_node_id']] = ['state' => 'visited', 'label' => 'Gateway available for service'];
                }
                if (isset($binding['relay_node_id'])) {
                    $nodes[(string)$binding['relay_node_id']] = ['state' => 'visited', 'label' => 'Hardware relay available for service'];
                }
            }
        }
        foreach ($actions as $action) {
            $nodes[(string)$action['node_id']] = [
                'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable',
                'label' => (string)($action['reason'] ?? 'Action evaluated.'),
            ];
        }

        $edges = [];
        foreach ((array)($graph['edges'] ?? []) as $edge) {
            if (!is_array($edge)) {
                continue;
            }
            $edgeId = (string)($edge['id'] ?? '');
            $source = (string)($edge['source'] ?? '');
            $target = (string)($edge['target'] ?? '');
            if ($edgeId === '' || (!isset($nodes[$source]) && !isset($nodes[$target]))) {
                continue;
            }
            $sourceState = (string)($nodes[$source]['state'] ?? 'visited');
            $targetState = (string)($nodes[$target]['state'] ?? 'visited');
            $edges[$edgeId] = [
                'state' => $this->mergeAnnotationStates($sourceState, $targetState),
                'label' => (string)($edge['label'] ?? 'Simulated relationship'),
            ];
        }

        if (($snapshot['machine_available'] ?? false) !== true) {
            $nodes['lane:' . (int)($snapshot['lane']['id'] ?? 0)] = ['state' => 'error', 'label' => 'Lane machine relay is not configured.'];
        }

        return [
            'nodes' => $nodes,
            'edges' => $edges,
        ];
    }

    protected function debugRuleSatisfied(string $type, mixed $actual): bool
    {
        return match (strtoupper(trim($type))) {
            'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => $actual === true,
            'IS_FALSE' => $actual === false,
            'IS_SET' => $actual !== null,
            'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null,
            'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null,
            default => false,
        };
    }

    protected function debugRuleExpectedValue(string $type): mixed
    {
        return match (strtoupper(trim($type))) {
            'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
            'IS_FALSE' => false,
            'IS_SET' => 'set',
            'IS_TRUE_OR_NOT_SET' => 'true or missing',
            'IS_FALSE_OR_NOT_SET' => 'false or missing',
            default => strtolower(str_replace('_', ' ', trim($type))),
        };
    }

    protected function debugExpectedLabel(mixed $expected): string
    {
        return $this->debugValueLabel($expected);
    }

    protected function debugValueLabel(mixed $value): string
    {
        if ($value === true) {
            return 'true';
        }
        if ($value === false) {
            return 'false';
        }
        if ($value === null) {
            return 'missing';
        }
        if (is_array($value)) {
            $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            return $encoded === false ? 'array' : $encoded;
        }
        if ($value instanceof \Stringable) {
            return (string)$value;
        }
        return trim((string)$value) === '' ? 'empty' : (string)$value;
    }

    /**
     * @return array<string,mixed>
     */
    protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
    {
        return [
            'kind' => $kind,
            'id' => $id,
            'label' => $label,
            'expected' => $expected,
            'actual' => $actual,
            'expected_label' => $this->debugExpectedLabel($expected),
            'actual_label' => $this->debugValueLabel($actual),
            'reason' => $reason,
        ];
    }

    protected function debugExpressionTraceReason(mixed $trace): ?string
    {
        if (!is_array($trace)) {
            return null;
        }
        $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
        $failed = $this->firstFailedPredicateTrace($expression);
        if ($failed !== null) {
            return (string)($failed['reason'] ?? 'Predicate did not pass.');
        }
        return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
    }

    /**
     * @param array<string,mixed> $trace
     * @param array<string,mixed> $lookups
     * @return array<string,mixed>|null
     */
    protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
    {
        $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
        $failed = $this->firstFailedPredicateTrace($expression);
        if ($failed === null) {
            if (($expression['result'] ?? true) === false) {
                return $this->debugCause(
                    'expression',
                    $trace['condition_id'] ?? null,
                    'Condition expression',
                    true,
                    false,
                    (string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
                );
            }
            return null;
        }

        $subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
        $subjectId = (int)($failed['subject_id'] ?? 0);
        $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
        $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
        $expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
        $actual = $failed['actual_value'] ?? null;
        $reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';

        return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
    }

    /**
     * @param array<int,bool|null> $conditionResults
     * @param array<int,bool|null> $visibleAnswers
     * @return array{reason:string,causes:array<int,array<string,mixed>>}
     */
    protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
    {
        $gateType = strtoupper(trim($gateType));
        if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
            if (!$active && $gateSatisfied) {
                return [
                    'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
                    'causes' => [
                        $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
                    ],
                ];
            }
            return [
                'reason' => $active
                    ? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
                    : 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
                'causes' => $active ? [] : [
                    $this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
                ],
            ];
        }

        $actual = $gateType === selfserve_task_gate_type::CONDITION->value
            ? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
            : ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
        $sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
        if (!$active && $gateSatisfied) {
            return [
                'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
                'causes' => [
                    $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
                    $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
                ],
            ];
        }
        $reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';

        return [
            'reason' => $reason,
            'causes' => $active ? [] : [
                $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
            ],
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugDynamicImageButtons(array $tasks): array
    {
        $items = [];
        foreach ($tasks as $task) {
            $taskId = (int)($task['id'] ?? 0);
            if ($taskId <= 0) {
                continue;
            }
            $taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
            $active = (bool)($task['active'] ?? false);
            foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
                $buttonLabel = $this->debugDynamicImageButtonLabel($button);
                $id = $taskId . ':' . (int)$index;
                $nodeId = 'dynamic_image_button:' . $id;
                $reason = $active
                    ? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
                    : 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
                $items[] = [
                    'kind' => 'dynamic_image_button',
                    'id' => $id,
                    'node_id' => $nodeId,
                    'node_ids' => ['task:' . $taskId, $nodeId],
                    'label' => $buttonLabel,
                    'task_id' => $taskId,
                    'task_label' => $taskLabel,
                    'button_index' => (int)$index,
                    'button' => $button,
                    'state' => $active ? 'active' : 'hidden',
                    'reason' => $reason,
                    'causes' => $active ? [] : [
                        $this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
                    ],
                ];
            }
        }

        return $items;
    }

    protected function taskUsesProgramPicker(array $task): bool
    {
        if (in_array(
            'PROGRAM_PICKER',
            $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
            true
        )) {
            return true;
        }

        return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
    }

    protected function isProgramNumberButton(mixed $button): bool
    {
        return is_int($button) && $button >= 0 && $button <= 11;
    }

    protected function dynamicImageButtonSequenceForTask(array $task): array
    {
        $buttons = $this->normalizeButtonList($task['buttons'] ?? null);
        if (!$this->taskUsesProgramPicker($task)) {
            return $buttons;
        }

        $sequence = ['program_picker'];
        foreach ($buttons as $button) {
            if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
                continue;
            }
            $sequence[] = $button;
        }

        return $this->normalizeButtonList($sequence);
    }

    /**
     * @param array<int,array<string,mixed>> $questions
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @param array<int,array<string,mixed>> $tasks
     * @param array<int,array<string,mixed>> $actions
     * @param array<int,array<string,mixed>> $signals
     * @param array<int,array<string,mixed>> $dynamicImageButtons
     * @return array<int,array<string,mixed>>
     */
    protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
    {
        $decisions = [];
        foreach ([
            'question' => $questions,
            'condition' => $conditions,
            'rule' => $rules,
            'task' => $tasks,
            'action' => $actions,
            'signal' => $signals,
            'dynamic_image_button' => $dynamicImageButtons,
        ] as $kind => $items) {
            foreach ($items as $item) {
                if (is_array($item)) {
                    $decisions[] = $this->debugDecisionFromItem($kind, $item);
                }
            }
        }

        return $decisions;
    }

    /**
     * @param array<string,mixed> $item
     * @return array<string,mixed>
     */
    protected function debugDecisionFromItem(string $kind, array $item): array
    {
        $nodeIds = [];
        foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
            if (is_string($nodeId) && $nodeId !== '') {
                $nodeIds[] = $nodeId;
            }
        }
        if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
            $nodeIds[] = $item['node_id'];
        }

        $state = (string)($item['state'] ?? '');
        if ($state === '') {
            $state = match ($kind) {
                'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
                'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
                'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
                'signal' => (string)($item['predicted_status'] ?? 'unknown'),
                default => 'unknown',
            };
        }

        $causes = [];
        foreach ((array)($item['causes'] ?? []) as $cause) {
            if (is_array($cause)) {
                $causes[] = $cause;
            }
        }

        return [
            'kind' => (string)($item['kind'] ?? $kind),
            'id' => $item['id'] ?? ($item['node_id'] ?? null),
            'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
            'state' => $state,
            'reason' => (string)($item['reason'] ?? ''),
            'node_ids' => array_values(array_unique($nodeIds)),
            'causes' => $causes,
        ];
    }

    protected function debugDynamicImageButtonLabel(mixed $button): string
    {
        if (is_array($button)) {
            foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
                if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
                    return (string)$button[$key];
                }
            }
            $encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            return $encoded === false ? 'Button' : $encoded;
        }
        $label = trim((string)$button);
        if (strtolower($label) === 'program_picker') {
            return 'Program picker';
        }

        return $label === '' ? 'Button' : $label;
    }

    /**
     * @param array<int,array<string,mixed>> $conditions
     * @param array<int,array<string,mixed>> $rules
     * @return array<int,int>
     */
    protected function detectConditionCycles(array $conditions, array $rules): array
    {
        $edges = [];
        foreach ($conditions as $condition) {
            $conditionId = (int)($condition['id'] ?? 0);
            $parentId = $this->nullableInt($condition['condition_id'] ?? null);
            if ($conditionId > 0 && $parentId !== null) {
                $edges[$conditionId][] = $parentId;
            }
        }
        foreach ($rules as $rule) {
            if (strtolower((string)($rule['object_type'] ?? '')) !== 'condition') {
                continue;
            }
            $conditionId = (int)($rule['condition_id'] ?? 0);
            $objectId = (int)($rule['object_id'] ?? 0);
            if ($conditionId > 0 && $objectId > 0) {
                $edges[$conditionId][] = $objectId;
            }
        }

        $visiting = [];
        $visited = [];
        $cycles = [];
        $walk = function (int $conditionId) use (&$walk, &$visiting, &$visited, &$cycles, $edges): void {
            if (isset($visited[$conditionId])) {
                return;
            }
            if (isset($visiting[$conditionId])) {
                $cycles[$conditionId] = $conditionId;
                return;
            }
            $visiting[$conditionId] = true;
            foreach ($edges[$conditionId] ?? [] as $nextId) {
                $walk((int)$nextId);
                if (isset($cycles[(int)$nextId])) {
                    $cycles[$conditionId] = $conditionId;
                }
            }
            unset($visiting[$conditionId]);
            $visited[$conditionId] = true;
        };

        foreach (array_keys($edges) as $conditionId) {
            $walk((int)$conditionId);
        }

        return array_values($cycles);
    }

    /**
     * @param array<string,mixed> $expression
     * @param array<string,mixed> $lookups
     */
    protected function debugExpressionSummary(array $expression, array $lookups): string
    {
        $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
        if ($type === 'predicate') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
            $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE'));
            $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
            $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
            return $label . ' ' . strtolower(str_replace('_', ' ', $operator));
        }

        $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
        $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
        if ($children === []) {
            return 'No predicates';
        }

        $parts = [];
        foreach (array_slice($children, 0, 3) as $child) {
            if (is_array($child)) {
                $parts[] = $this->debugExpressionSummary((array)$child, $lookups);
            }
        }
        if (count($children) > 3) {
            $parts[] = '+' . (count($children) - 3) . ' more';
        }

        return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts);
    }

    /**
     * @param array<string,mixed> $trace
     * @param array<string,mixed> $lookups
     */
    protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string
    {
        $failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace));
        if ($failed === null) {
            return null;
        }

        $subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
        $subjectId = (int)($failed['subject_id'] ?? 0);
        $operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE'));
        $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
        $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);

        return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.';
    }

    /**
     * @param array<string,mixed> $trace
     * @return array<string,mixed>|null
     */
    protected function firstFailedPredicateTrace(array $trace): ?array
    {
        if (($trace['type'] ?? '') === 'predicate') {
            return (($trace['result'] ?? false) === true) ? null : $trace;
        }
        foreach ((array)($trace['children'] ?? []) as $child) {
            if (!is_array($child)) {
                continue;
            }
            $failed = $this->firstFailedPredicateTrace((array)$child);
            if ($failed !== null) {
                return $failed;
            }
        }
        foreach (['when', 'then', 'default'] as $field) {
            if (!is_array($trace[$field] ?? null)) {
                continue;
            }
            $failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
            if ($failed !== null) {
                return $failed;
            }
        }
        foreach (['branches', 'cases'] as $field) {
            foreach ((array)($trace[$field] ?? []) as $child) {
                if (!is_array($child)) {
                    continue;
                }
                $failed = $this->firstFailedPredicateTrace((array)$child);
                if ($failed !== null) {
                    return $failed;
                }
            }
        }
        return null;
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<string,array<int,string>>
     */
    protected function debugRelayServicesFromWorkspace(array $workspace): array
    {
        $servicesByRelay = [];
        foreach ((array)($workspace['lanes'] ?? []) as $lane) {
            if (!is_array($lane)) {
                continue;
            }
            foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
                if (!is_array($slot)) {
                    continue;
                }
                $relayId = trim((string)($slot['relay_id'] ?? ''));
                if ($relayId === '') {
                    continue;
                }
                foreach ($this->debugNormalizeServiceValues($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '') as $service) {
                    $servicesByRelay[$relayId][$service] = true;
                }
            }
        }

        return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay);
    }

    /**
     * @param array<string,mixed> $binding
     * @param array<string,array<int,string>> $relayServices
     * @return array<int,string>
     */
    protected function debugBindingServices(array $binding, string $relayId, array $relayServices): array
    {
        $services = [];
        $addServices = function (mixed $value) use (&$services): void {
            foreach ($this->debugNormalizeServiceValues($value) as $service) {
                $services[$service] = true;
            }
        };

        foreach (['role', 'service', 'slot'] as $field) {
            $addServices($binding[$field] ?? null);
        }
        $addServices($binding['services'] ?? []);

        foreach ($this->debugBindingConsumerContexts($binding) as $context) {
            if (!is_array($context)) {
                continue;
            }
            foreach (['slot', 'role', 'service'] as $field) {
                $addServices($context[$field] ?? null);
            }
        }

        foreach ((array)($relayServices[$relayId] ?? []) as $service) {
            $addServices($service);
        }

        return array_keys($services);
    }

    /**
     * @param array<string,mixed> $binding
     * @return array<int,mixed>
     */
    protected function debugBindingConsumerContexts(array $binding): array
    {
        $contexts = [];
        foreach (['consumer_contexts', 'consumers'] as $field) {
            foreach ((array)($binding[$field] ?? []) as $context) {
                $contexts[] = $context;
            }
        }
        $metadata = is_array($binding['metadata'] ?? null) ? (array)$binding['metadata'] : [];
        foreach (['consumer_contexts', 'consumers'] as $field) {
            foreach ((array)($metadata[$field] ?? []) as $context) {
                $contexts[] = $context;
            }
        }

        return $contexts;
    }

    /**
     * @return array<int,string>
     */
    protected function debugNormalizeServiceValues(mixed $value): array
    {
        if (is_string($value)) {
            $trimmed = trim($value);
            if ($trimmed === '') {
                return [];
            }
            $decoded = json_decode($trimmed, true);
            $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded)
                ? $decoded
                : explode(',', $trimmed);
        }

        if (!is_array($value)) {
            $value = [$value];
        }

        $services = [];
        foreach ($value as $entry) {
            if (is_array($entry)) {
                continue;
            }
            $service = strtoupper(trim((string)$entry));
            if ($service !== '') {
                $services[$service] = true;
            }
        }

        return array_keys($services);
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<string,array<int,array<string,mixed>>>
     */
    protected function debugGatewayBindingsByService(array $workspace): array
    {
        $bindings = [];
        foreach ($this->debugGatewayBindingReferences($workspace) as $binding) {
            foreach ((array)($binding['services'] ?? []) as $service) {
                $row = $binding;
                $row['service'] = $service;
                $bindings[$service][] = $row;
            }
        }

        return $bindings;
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<int,array<string,mixed>>
     */
    protected function debugGatewayBindingReferences(array $workspace): array
    {
        $references = [];
        $relayServices = $this->debugRelayServicesFromWorkspace($workspace);
        foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
            if (!is_array($gateway)) {
                continue;
            }
            $gatewayId = $this->debugGatewayIdentifier($gateway);
            if ($gatewayId === '') {
                continue;
            }
            foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
                if (!is_array($binding)) {
                    continue;
                }
                $relayId = trim((string)($binding['relay_id'] ?? ''));
                if ($relayId === '') {
                    continue;
                }
                $services = $this->debugBindingServices($binding, $relayId, $relayServices);
                if ($services === []) {
                    continue;
                }
                $references[] = [
                    'gateway_id' => $gatewayId,
                    'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)),
                    'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'),
                    'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)),
                    'relay_id' => $relayId,
                    'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)),
                    'relay_node_id' => 'relay:' . $relayId,
                    'binding_index' => (int)$index,
                    'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)),
                    'services' => $services,
                    'channel' => $binding['channel'] ?? null,
                    'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
                ];
            }
        }

        return $references;
    }

    /**
     * @param array<string,mixed> $gateway
     */
    protected function debugGatewayIdentifier(array $gateway): string
    {
        return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''));
    }

    /**
     * @param array<string,mixed> $snapshot
     * @param array<int,string> $missingBindings
     * @param array<string,mixed> $lookups
     */
    protected function debugHardwareSummary(array $snapshot, array $missingBindings, array $lookups): string
    {
        if (($snapshot['machine_available'] ?? false) !== true) {
            return 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' has no machine relay configured.';
        }
        if ($missingBindings !== []) {
            return 'Lane relay is configured, but gateway bindings are missing for: ' . implode(', ', $missingBindings) . '.';
        }
        return 'Lane relay and gateway service bindings are ready for the simulated services.';
    }

    /**
     * @param array<string,mixed> $lookups
     */
    protected function debugLabel(array $lookups, string $type, mixed $id, string $fallback): string
    {
        $key = (string)($id ?? '');
        if ($key === '' || $key === '0') {
            return $fallback;
        }
        if (isset($lookups['labels'][$type][$key])) {
            return (string)$lookups['labels'][$type][$key];
        }
        foreach ((array)($lookups[$type] ?? []) as $row) {
            if (is_array($row) && (string)($row['id'] ?? '') === $key) {
                return (string)($row['label'] ?? $fallback);
            }
        }
        return $fallback;
    }

    /**
     * @param array<string,mixed> $lookups
     */
    protected function debugGateReferenceLabel(array $lookups, string $gateType, int $gateRefId): string
    {
        $gateType = strtoupper(trim($gateType));
        if ($gateType === selfserve_task_gate_type::CONDITION->value) {
            return $this->debugLabel($lookups, 'conditions', $gateRefId, 'Condition ' . $gateRefId);
        }
        if ($gateType === selfserve_task_gate_type::QUESTION->value) {
            return $this->debugLabel($lookups, 'questions', $gateRefId, 'Question ' . $gateRefId);
        }
        return 'Always';
    }

    protected function mergeAnnotationStates(string $left, string $right): string
    {
        $rank = [
            'error' => 6,
            'blocked' => 5,
            'warning' => 4,
            'active' => 3,
            'visited' => 2,
            'not_applicable' => 1,
        ];
        return (($rank[$left] ?? 0) >= ($rank[$right] ?? 0)) ? $left : $right;
    }

    /**
     * @param array<string,mixed>|null $publishedConfig
     */
    protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?array $publishedConfig = null): array
    {
        if (is_array($publishedConfig) && isset($publishedConfig['questions']) && is_array($publishedConfig['questions'])) {
            $questions = array_values(array_filter($publishedConfig['questions'], static function (array $question) use ($departmentId): bool {
                return ((int)($question['department'] ?? 0) === 0) || ((int)($question['department'] ?? 0) === $departmentId);
            }));

            $sharedQuestions = array_values(array_filter($questions, static function (array $question): bool {
                return (int)($question['department'] ?? 0) === 0
                    && (int)($question['lane'] ?? 0) === 0
                    && (int)($question['product'] ?? 0) === 0;
            }));
            if ($sharedQuestions !== []) {
                return $sharedQuestions;
            }

            if ($vehicleTypeId === null) {
                return [];
            }

            return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
                return (int)($question['department'] ?? 0) === $departmentId
                    && ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
                    && (int)($question['product'] ?? 0) === $vehicleTypeId;
            }));
        }

        $questionsObject = new department_selfserve_questions_o();
        $sharedQuestions = $questionsObject->getSharedQuestions();
        if ($sharedQuestions !== []) {
            return $sharedQuestions;
        }
        if ($vehicleTypeId === null) {
            return [];
        }

        return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId);
    }

    /**
     * @param array<string,mixed>|null $publishedConfig
     */
    protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array
    {
        if (is_array($publishedConfig) && isset($publishedConfig['conditions']) && is_array($publishedConfig['conditions'])) {
            $conditions = array_values(array_filter($publishedConfig['conditions'], static function (array $condition) use ($departmentId): bool {
                return ((int)($condition['department'] ?? 0) === 0) || ((int)($condition['department'] ?? 0) === $departmentId);
            }));

            if ($machineTypeId !== null) {
                $machineTypeConditions = array_values(array_filter($conditions, static function (array $condition) use ($machineTypeId): bool {
                    return (int)($condition['machine_type_id'] ?? 0) === $machineTypeId;
                }));
                if ($machineTypeConditions !== []) {
                    return $machineTypeConditions;
                }
            }

            if ($vehicleTypeId === null) {
                return [];
            }

            return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
                return (int)($condition['machine_type_id'] ?? 0) === 0
                    && (int)($condition['department'] ?? 0) === $departmentId
                    && ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
                    && (int)($condition['product'] ?? 0) === $vehicleTypeId;
            }));
        }

        $conditionsObject = new department_selfserve_conditions_o();
        if ($machineTypeId !== null) {
            $machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId);
            if ($machineTypeConditions !== []) {
                return $machineTypeConditions;
            }
        }
        if ($vehicleTypeId === null) {
            return [];
        }

        return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId);
    }

    /**
     * @param array<string,mixed>|null $publishedConfig
     */
    protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array
    {
        if (is_array($publishedConfig) && isset($publishedConfig['tasks']) && is_array($publishedConfig['tasks'])) {
            $tasks = array_values(array_filter($publishedConfig['tasks'], static function (array $task) use ($departmentId): bool {
                return ((int)($task['department'] ?? 0) === 0) || ((int)($task['department'] ?? 0) === $departmentId);
            }));

            if ($machineTypeId !== null) {
                $machineTypeTasks = array_values(array_filter($tasks, static function (array $task) use ($machineTypeId): bool {
                    return (int)($task['machine_type_id'] ?? 0) === $machineTypeId;
                }));
                if ($machineTypeTasks !== []) {
                    return $machineTypeTasks;
                }
            }
            if ($vehicleTypeId === null) {
                return [];
            }

            return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
                return (int)($task['machine_type_id'] ?? 0) === 0
                    && (int)($task['department'] ?? 0) === $departmentId
                    && ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
                    && (int)($task['product'] ?? 0) === $vehicleTypeId;
            }));
        }

        $tasksObject = new department_selfserve_tasks_o();
        if ($machineTypeId !== null) {
            $machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId);
            if ($machineTypeTasks !== []) {
                return $machineTypeTasks;
            }
        }
        if ($vehicleTypeId === null) {
            return [];
        }

        return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId);
    }

    /**
     * @param array<string,mixed> $task
     * @param array<int,int> $conditionIds
     * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null}
     */
    protected function resolveTaskGate(array $task, array $conditionIds): array
    {
        $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? ''))));
        $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
        $legacyGateId = $this->nullableInt($task['condition_id'] ?? null);

        if (
            $gateType === selfserve_task_gate_type::CONDITION
            || $gateType === selfserve_task_gate_type::QUESTION
        ) {
            return [
                'gate_type' => $gateType,
                'gate_ref_id' => $gateRefId ?? $legacyGateId,
            ];
        }

        $shouldInferLegacyGate = $gateType === null
            || (
                $gateType === selfserve_task_gate_type::ALWAYS
                && $gateRefId === null
                && $legacyGateId !== null
            );

        if ($shouldInferLegacyGate) {
            $fallbackGateId = $gateRefId ?? $legacyGateId;
            if ($fallbackGateId === null) {
                return [
                    'gate_type' => selfserve_task_gate_type::ALWAYS,
                    'gate_ref_id' => null,
                ];
            }

            return [
                'gate_type' => in_array($fallbackGateId, $conditionIds, true)
                    ? selfserve_task_gate_type::CONDITION
                    : selfserve_task_gate_type::QUESTION,
                'gate_ref_id' => $fallbackGateId,
            ];
        }

        return [
            'gate_type' => selfserve_task_gate_type::ALWAYS,
            'gate_ref_id' => null,
        ];
    }

    /**
     * @param array<string,mixed>|null $publishedConfig
     */
    protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array
    {
        if ($conditions === []) {
            return [];
        }

        if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) {
            return [];
        }

        $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);

        if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) {
            return array_values(array_filter($publishedConfig['rules'], static function (array $rule) use ($conditionIds): bool {
                return in_array((int)($rule['condition_id'] ?? 0), $conditionIds, true);
            }));
        }

        return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([
            'condition_id' => $conditionIds,
            'deleted_at' => null,
        ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']);
    }

    protected function syncSessionAnswers(int $sessionId, array $questions): void
    {
        $answersObject = new selfserve_wash_session_answers_o();
        $answeredQuestionIds = [];
        foreach ($questions as $question) {
            if ($question['answer'] === null) {
                continue;
            }
            $answeredQuestionIds[] = (int)$question['id'];
            $answersObject->upsert(
                $sessionId,
                (int)$question['id'],
                (string)$question['question'],
                (bool)$question['answer'],
            );
        }
        $answersObject->deleteMissingForSession($sessionId, $answeredQuestionIds);
    }

    protected function syncSessionTasks(int $sessionId, array $tasks): void
    {
        $tasksObject = new selfserve_wash_session_tasks_o();
        $tasksObject->deleteBySession($sessionId);

        foreach ($tasks as $task) {
            $tasksObject->addSnapshot(
                $sessionId,
                (int)$task['id'],
                (string)$task['task'],
                (string)$task['description'],
                $task['services'],
                $task['buttons'],
                $task['dynamic_images_vehicle_type'] ?? null,
            );
        }
    }

    protected function logSessionEvent(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): void
    {
        (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
    }

    /**
     * @param array<string,mixed> $options
     */
    protected function resolveActorSubuserId(array $options): ?int
    {
        return $this->nullableInt($options['subuser_id'] ?? null);
    }

    protected function formatSessionSubuser(?int $subuserId): ?array
    {
        if ($subuserId === null || $subuserId <= 0) {
            return null;
        }

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

            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' => $subuserId,
            ];
        }
    }

    /**
     * @param callable():array<string,mixed> $callback
     * @return array<string,mixed>
     */
    protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId, callable $callback): array
    {
        $lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
        $lock = $this->acquireSessionMutationLock($lockKey);

        try {
            return $callback();
        } finally {
            $this->releaseSessionMutationLock($lock);
        }
    }

    /**
     * @return array{driver: string, key: string, token: ?string}
     */
    protected function acquireSessionMutationLock(string $lockKey): array
    {
        if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
            $token = bin2hex(random_bytes(16));
            if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
                throw new \RuntimeException('Self-serve wash session is busy. Try again.');
            }

            return [
                'driver' => 'redis',
                'key' => $lockKey,
                'token' => $token,
            ];
        }

        global $db;
        $result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
        $row = $db->fetch_assoc($result);
        if ((int)($row['acquired'] ?? 0) !== 1) {
            throw new \RuntimeException('Self-serve wash session is busy. Try again.');
        }

        return [
            'driver' => 'mysql',
            'key' => $lockKey,
            'token' => null,
        ];
    }

    /**
     * @param array{driver: string, key: string, token: ?string} $lock
     */
    protected function releaseSessionMutationLock(array $lock): void
    {
        try {
            if ($lock['driver'] === 'redis' && defined('redis')) {
                if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
                    return;
                }
                if (method_exists(redis, 'delete')) {
                    redis->delete($lock['key']);
                }
                return;
            }

            if ($lock['driver'] === 'mysql') {
                global $db;
                $db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
            }
        } catch (\Throwable) {
            // Locks have TTLs or connection scope; release failures must not mask API results.
        }
    }

    protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
    {
        return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
            selfserve::standardize_registration($reg)
            . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
            . ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
        );
    }

    protected function buildSessionMetadata(array $snapshot): array
    {
        return [
            'allowed_services' => $snapshot['allowed_services'],
            'machine_available' => (bool)$snapshot['machine_available'],
            'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
            'subuser_id' => $snapshot['subuser_id'] ?? null,
            'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
            'config_version_id' => $snapshot['config_version_id'] ?? null,
            'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
            'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']),
            'visible_questions' => array_map(static fn(array $question): array => [
                'id' => (int)$question['id'],
                'question' => (string)$question['question'],
                'order_priority' => (int)($question['order_priority'] ?? 0),
            ], $snapshot['questions']),
            'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $answerRows
     * @return array<int,array<string,mixed>>
     */
    protected function buildSessionQuestions(selfserve_wash_sessions_o $session, array $answerRows): array
    {
        $answersByQuestionId = [];
        foreach ($answerRows as $row) {
            $answersByQuestionId[(int)$row['question_id']] = $row;
        }

        $metadata = $session->metadata_json->value();
        $metadata = is_array($metadata) ? $metadata : [];
        $visibleQuestions = $this->resolveVisibleQuestionsFromMetadata(
            $metadata,
            (int)$session->department_id->value(),
            (int)$session->lane_id->value(),
            $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
        );

        if ($visibleQuestions === []) {
            return array_map(static function (array $row): array {
                return [
                    'question_id' => (int)$row['question_id'],
                    'question' => (string)$row['question_text'],
                    'answer' => (bool)$row['answer_value'],
                    'answered_at' => (string)$row['answered_at'],
                ];
            }, $answerRows);
        }

        $questions = [];
        foreach ($visibleQuestions as $visibleQuestion) {
            $questionId = (int)$visibleQuestion['id'];
            if ($questionId <= 0) {
                continue;
            }

            $answerRow = $answersByQuestionId[$questionId] ?? null;
            $questions[] = [
                'question_id' => $questionId,
                'question' => $answerRow === null ? (string)$visibleQuestion['question'] : (string)$answerRow['question_text'],
                'answer' => $answerRow === null ? null : (bool)$answerRow['answer_value'],
                'answered_at' => $answerRow === null ? null : (string)$answerRow['answered_at'],
            ];
            unset($answersByQuestionId[$questionId]);
        }

        foreach ($answerRows as $row) {
            $questionId = (int)$row['question_id'];
            if (!array_key_exists($questionId, $answersByQuestionId)) {
                continue;
            }

            $questions[] = [
                'question_id' => $questionId,
                'question' => (string)$row['question_text'],
                'answer' => (bool)$row['answer_value'],
                'answered_at' => (string)$row['answered_at'],
            ];
            unset($answersByQuestionId[$questionId]);
        }

        return $questions;
    }

    /**
     * @param array<string,mixed> $metadata
     * @return array<int,array{id:int,question:string,order_priority:int}>
     */
    protected function resolveVisibleQuestionsFromMetadata(array $metadata, int $departmentId, int $laneId, ?int $vehicleTypeId): array
    {
        $visibleQuestions = [];
        if (isset($metadata['visible_questions']) && is_array($metadata['visible_questions'])) {
            foreach ($metadata['visible_questions'] as $visibleQuestion) {
                if (!is_array($visibleQuestion)) {
                    continue;
                }
                $questionId = (int)($visibleQuestion['id'] ?? 0);
                if ($questionId <= 0) {
                    continue;
                }
                $visibleQuestions[] = [
                    'id' => $questionId,
                    'question' => (string)($visibleQuestion['question'] ?? ''),
                    'order_priority' => (int)($visibleQuestion['order_priority'] ?? 0),
                ];
            }
            return $visibleQuestions;
        }

        if (!isset($metadata['visible_question_ids']) || !is_array($metadata['visible_question_ids'])) {
            return [];
        }

        $questionById = [];
        foreach ($this->loadQuestions($departmentId, $laneId, $vehicleTypeId) as $question) {
            $questionById[(int)$question['id']] = [
                'question' => (string)($question['question'] ?? ''),
                'order_priority' => (int)($question['order_priority'] ?? 0),
            ];
        }

        foreach ($metadata['visible_question_ids'] as $visibleQuestionId) {
            $questionId = (int)$visibleQuestionId;
            if ($questionId <= 0) {
                continue;
            }
            $visibleQuestions[] = [
                'id' => $questionId,
                'question' => (string)($questionById[$questionId]['question'] ?? ''),
                'order_priority' => (int)($questionById[$questionId]['order_priority'] ?? 0),
            ];
        }

        return $visibleQuestions;
    }

    protected function findVehicleByRegistration(string $reg): ?customer_vehicles_o
    {
        $vehicle = (new customer_vehicles_o())->selectByPlate($reg);
        return $vehicle->exists() ? $vehicle : null;
    }

    protected function resolveVehicleTypeId(?customer_vehicles_o $vehicle, ?int $vehicleTypeIdOverride = null): ?int
    {
        if ($vehicleTypeIdOverride !== null && $vehicleTypeIdOverride > 0) {
            return $vehicleTypeIdOverride;
        }
        if ($vehicle === null) {
            return null;
        }

        $vehicleTypeId = (int)$vehicle->type->value();
        return $vehicleTypeId > 0 ? $vehicleTypeId : null;
    }

    protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
    {
        $session = new selfserve_wash_sessions_o();
        $session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
        return $session;
    }

    protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
    {
        $rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
            [
                'lane_id' => $laneId,
                'completed_at' => null,
                'deleted_at' => null,
                ...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
                ...($subuserId !== null ? ['subuser_id' => $subuserId] : []),
            ],
            ['id', 'status']
        );
        $rows = array_values(array_filter(
            $rows,
            static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null)
        ));
        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']);
    }

    protected function resolveForceStopSession(int $laneId, ?int $sessionId = null): selfserve_wash_sessions_o
    {
        if ($sessionId === null) {
            return (new selfserve_wash_sessions_o())->selectLatestOpenByLane($laneId);
        }

        $session = (new selfserve_wash_sessions_o())->select($sessionId);
        if (!$session->exists()) {
            throw new \RuntimeException('Self-serve wash session not found.');
        }
        if ((int)$session->lane_id->value() !== $laneId) {
            throw new \RuntimeException('Self-serve wash session does not belong to the requested lane.');
        }
        if ($session->completed_at->value() !== null) {
            throw new \RuntimeException('Self-serve wash session is already closed.');
        }

        return $session;
    }

    protected function buildForceStopRuntimeSnapshot(selfserve_lane $lane): array
    {
        return [
            '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(),
        ];
    }

    protected function laneRuntimeLooksActive(array $snapshot): bool
    {
        return $snapshot['status'] === selfserve_lane_status::OCCUPIED->name
            || $snapshot['state'] === selfserve_lane_state::IN_WASH->name
            || (int)($snapshot['wash_start_time'] ?? 0) > 0
            || trim((string)($snapshot['license_plate'] ?? '')) !== ''
            || (int)($snapshot['customer_number'] ?? 0) > 0;
    }

    protected function nullableInt(mixed $value): ?int
    {
        if ($value === null || $value === '' || $value === 0 || $value === '0') {
            return null;
        }

        return (int)$value;
    }

    /**
     * @param array<int,bool|null> $answers
     * @param array<int,int> $visibleQuestionIds
     * @return array<int,bool|null>
     */
    protected function filterAnswersToVisibleQuestions(array $answers, array $visibleQuestionIds): array
    {
        $filtered = [];
        foreach ($visibleQuestionIds as $questionId) {
            $questionId = (int)$questionId;
            if ($questionId <= 0 || !array_key_exists($questionId, $answers)) {
                continue;
            }
            $filtered[$questionId] = $answers[$questionId];
        }
        return $filtered;
    }

    protected function normalizeJsonValue(mixed $value): mixed
    {
        if ($value === null || $value === '') {
            return null;
        }
        if (is_array($value)) {
            return $value;
        }
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            if (json_last_error() === JSON_ERROR_NONE) {
                return $decoded;
            }
        }
        return $value;
    }

    protected function normalizeJsonArray(mixed $value): array
    {
        $decoded = $this->normalizeJsonValue($value);
        return is_array($decoded) ? $decoded : [];
    }

    protected function normalizeButtonList(mixed $value): array
    {
        try {
            return department_selfserve_tasks_o::normalizeButtonsInput($value);
        } catch (\Throwable) {
            return [];
        }
    }

    /**
     * @param array<int,array<string,mixed>> $tasks
     * @param array<int,string> $allowedServices
     * @return array<int,array<string,mixed>>
     */
    protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array
    {
        if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) {
            return array_values($tasks);
        }

        return array_values(array_filter(
            $tasks,
            fn(array $task): bool => !$this->taskUsesMachineControls($task)
        ));
    }

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

    /**
     * @param array<int,string> $services
     * @return array<int,string>
     */
    protected function withoutMachineService(array $services): array
    {
        return array_values(array_filter(
            $this->normalizeServiceNames($services),
            static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
        ));
    }

    protected function taskUsesMachineControls(array $task): bool
    {
        if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
            return true;
        }

        if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) {
            return true;
        }

        $dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null;
        return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== '';
    }

    protected function normalizeServiceNames(array $services): array
    {
        $normalized = [];
        foreach ($services as $service) {
            $name = strtoupper(trim((string)$service));
            if ($name === '') {
                continue;
            }
            if (!in_array($name, $normalized, true)) {
                $normalized[] = $name;
            }
        }
        return $normalized;
    }

    protected function normalizeIntArray(array $values): array
    {
        $normalized = [];
        foreach ($values as $value) {
            $intValue = (int)$value;
            if (!in_array($intValue, $normalized, true)) {
                $normalized[] = $intValue;
            }
        }
        return $normalized;
    }

    public function isMachineAllowedToStartWash(int $id): bool
    {
        $sessionSummary = $this->getSessionSummary($id);
        return ($sessionSummary['session']['allowed'] ?? false) === true;
    }
}
